Просмотр исходного кода

Merge branch 'dev' into feat/hashline-edit-experimental-v2

Shoubhit Dash 5 месяцев назад
Родитель
Сommit
ab44597018

+ 340 - 238
packages/app/src/components/dialog-select-server.tsx

@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { List } from "@opencode-ai/ui/list"
 import { TextField } from "@opencode-ai/ui/text-field"
@@ -9,32 +10,27 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
-import { ServerRow } from "@/components/server/server-row"
+import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
-interface AddRowProps {
-  value: string
-  placeholder: string
-  adding: boolean
-  error: string
-  status: boolean | undefined
-  onChange: (value: string) => void
-  onKeyDown: (event: KeyboardEvent) => void
-  onBlur: () => void
-}
-
-interface EditRowProps {
+interface ServerFormProps {
   value: string
+  name: string
+  username: string
+  password: string
   placeholder: string
   busy: boolean
   error: string
   status: boolean | undefined
   onChange: (value: string) => void
-  onKeyDown: (event: KeyboardEvent) => void
-  onBlur: () => void
+  onNameChange: (value: string) => void
+  onUsernameChange: (value: string) => void
+  onPasswordChange: (value: string) => void
+  onSubmit: () => void
+  onBack: () => void
 }
 
 function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
@@ -83,83 +79,86 @@ function useServerPreview(fetcher: typeof fetch) {
     return host.includes(".") || host.includes(":")
   }
 
-  const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
+  const previewStatus = async (
+    value: string,
+    username: string,
+    password: string,
+    setStatus: (value: boolean | undefined) => void,
+  ) => {
     setStatus(undefined)
     if (!looksComplete(value)) return
     const normalized = normalizeServerUrl(value)
     if (!normalized) return
-    const result = await checkServerHealth({ url: normalized }, fetcher)
+    const http: ServerConnection.HttpBase = { url: normalized }
+    if (username) http.username = username
+    if (password) http.password = password
+    const result = await checkServerHealth(http, fetcher)
     setStatus(result.healthy)
   }
 
   return { previewStatus }
 }
 
-function AddRow(props: AddRowProps) {
-  return (
-    <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
-      <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
-        <div
-          classList={{
-            "size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
-            "bg-icon-success-base": props.status === true,
-            "bg-icon-critical-base": props.status === false,
-            "bg-border-weak-base": props.status === undefined,
-          }}
-          ref={(el) => {
-            // Position relative to input-wrapper
-            requestAnimationFrame(() => {
-              const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
-              if (wrapper instanceof HTMLElement) {
-                wrapper.appendChild(el)
-              }
-            })
-          }}
-        />
-        <TextField
-          type="text"
-          hideLabel
-          placeholder={props.placeholder}
-          value={props.value}
-          autofocus
-          validationState={props.error ? "invalid" : "valid"}
-          error={props.error}
-          disabled={props.adding}
-          onChange={props.onChange}
-          onKeyDown={props.onKeyDown}
-          onBlur={props.onBlur}
-          class="pl-7"
-        />
-      </div>
-    </div>
-  )
-}
+function ServerForm(props: ServerFormProps) {
+  const language = useLanguage()
+  const keyDown = (event: KeyboardEvent) => {
+    event.stopPropagation()
+    if (event.key === "Escape") {
+      event.preventDefault()
+      props.onBack()
+      return
+    }
+    if (event.key !== "Enter" || event.isComposing) return
+    event.preventDefault()
+    props.onSubmit()
+  }
 
-function EditRow(props: EditRowProps) {
   return (
-    <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
-      <div
-        classList={{
-          "size-1.5 rounded-full shrink-0": true,
-          "bg-icon-success-base": props.status === true,
-          "bg-icon-critical-base": props.status === false,
-          "bg-border-weak-base": props.status === undefined,
-        }}
-      />
-      <div class="flex-1 min-w-0">
+    <div class="px-5">
+      <div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
+        <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
+          <TextField
+            type="text"
+            label={language.t("dialog.server.add.url")}
+            placeholder={props.placeholder}
+            value={props.value}
+            autofocus
+            validationState={props.error ? "invalid" : "valid"}
+            error={props.error}
+            disabled={props.busy}
+            onChange={props.onChange}
+            onKeyDown={keyDown}
+          />
+        </div>
         <TextField
           type="text"
-          hideLabel
-          placeholder={props.placeholder}
-          value={props.value}
-          autofocus
-          validationState={props.error ? "invalid" : "valid"}
-          error={props.error}
+          label={language.t("dialog.server.add.name")}
+          placeholder={language.t("dialog.server.add.namePlaceholder")}
+          value={props.name}
           disabled={props.busy}
-          onChange={props.onChange}
-          onKeyDown={props.onKeyDown}
-          onBlur={props.onBlur}
+          onChange={props.onNameChange}
+          onKeyDown={keyDown}
         />
+        <div class="grid grid-cols-2 gap-2 min-w-0">
+          <TextField
+            type="text"
+            label={language.t("dialog.server.add.username")}
+            placeholder="username"
+            value={props.username}
+            disabled={props.busy}
+            onChange={props.onUsernameChange}
+            onKeyDown={keyDown}
+          />
+          <TextField
+            type="password"
+            label={language.t("dialog.server.add.password")}
+            placeholder="password"
+            value={props.password}
+            disabled={props.busy}
+            onChange={props.onPasswordChange}
+            onKeyDown={keyDown}
+          />
+        </div>
       </div>
     </div>
   )
@@ -174,11 +173,13 @@ export function DialogSelectServer() {
   const fetcher = platform.fetch ?? globalThis.fetch
   const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
   const { previewStatus } = useServerPreview(fetcher)
-  let listRoot: HTMLDivElement | undefined
   const [store, setStore] = createStore({
     status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
     addServer: {
       url: "",
+      name: "",
+      username: "",
+      password: "",
       adding: false,
       error: "",
       showForm: false,
@@ -187,6 +188,9 @@ export function DialogSelectServer() {
     editServer: {
       id: undefined as string | undefined,
       value: "",
+      name: "",
+      username: "",
+      password: "",
       error: "",
       busy: false,
       status: undefined as boolean | undefined,
@@ -196,27 +200,32 @@ export function DialogSelectServer() {
   const resetAdd = () => {
     setStore("addServer", {
       url: "",
+      name: "",
+      username: "",
+      password: "",
+      adding: false,
       error: "",
       showForm: false,
       status: undefined,
     })
   }
-
   const resetEdit = () => {
     setStore("editServer", {
       id: undefined,
       value: "",
+      name: "",
+      username: "",
+      password: "",
       error: "",
       status: undefined,
       busy: false,
     })
   }
 
-  const replaceServer = (original: ServerConnection.Http, next: string) => {
+  const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
     const active = server.key
     const newConn = server.add(next)
     if (!newConn) return
-
     const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
     if (nextActive) server.setActive(nextActive)
     server.remove(ServerConnection.key(original))
@@ -271,8 +280,8 @@ export function DialogSelectServer() {
   async function select(conn: ServerConnection.Any, persist?: boolean) {
     if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
     dialog.close()
-    if (persist) {
-      server.add(conn.http.url)
+    if (persist && conn.type === "http") {
+      server.add(conn)
       navigate("/")
       return
     }
@@ -283,21 +292,59 @@ export function DialogSelectServer() {
   const handleAddChange = (value: string) => {
     if (store.addServer.adding) return
     setStore("addServer", { url: value, error: "" })
-    void previewStatus(value, (next) => setStore("addServer", { status: next }))
+    void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
+      setStore("addServer", { status: next }),
+    )
   }
 
-  const scrollListToBottom = () => {
-    const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
-    if (!scroll) return
-    requestAnimationFrame(() => {
-      scroll.scrollTop = scroll.scrollHeight
-    })
+  const handleAddNameChange = (value: string) => {
+    if (store.addServer.adding) return
+    setStore("addServer", { name: value, error: "" })
+  }
+
+  const handleAddUsernameChange = (value: string) => {
+    if (store.addServer.adding) return
+    setStore("addServer", { username: value, error: "" })
+    void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
+      setStore("addServer", { status: next }),
+    )
+  }
+
+  const handleAddPasswordChange = (value: string) => {
+    if (store.addServer.adding) return
+    setStore("addServer", { password: value, error: "" })
+    void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
+      setStore("addServer", { status: next }),
+    )
   }
 
   const handleEditChange = (value: string) => {
     if (store.editServer.busy) return
     setStore("editServer", { value, error: "" })
-    void previewStatus(value, (next) => setStore("editServer", { status: next }))
+    void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
+      setStore("editServer", { status: next }),
+    )
+  }
+
+  const handleEditNameChange = (value: string) => {
+    if (store.editServer.busy) return
+    setStore("editServer", { name: value, error: "" })
+  }
+
+  const handleEditUsernameChange = (value: string) => {
+    if (store.editServer.busy) return
+    setStore("editServer", { username: value, error: "" })
+    void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
+      setStore("editServer", { status: next }),
+    )
+  }
+
+  const handleEditPasswordChange = (value: string) => {
+    if (store.editServer.busy) return
+    setStore("editServer", { password: value, error: "" })
+    void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
+      setStore("editServer", { status: next }),
+    )
   }
 
   async function handleAdd(value: string) {
@@ -310,16 +357,22 @@ export function DialogSelectServer() {
 
     setStore("addServer", { adding: true, error: "" })
 
-    const result = await checkServerHealth({ url: normalized }, fetcher)
+    const conn: ServerConnection.Http = {
+      type: "http",
+      http: { url: normalized },
+    }
+    if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
+    if (store.addServer.username) conn.http.username = store.addServer.username
+    if (store.addServer.password) conn.http.password = store.addServer.password
+    const result = await checkServerHealth(conn.http, fetcher)
     setStore("addServer", { adding: false })
-
     if (!result.healthy) {
       setStore("addServer", { error: language.t("dialog.server.add.error") })
       return
     }
 
     resetAdd()
-    await select({ type: "http", http: { url: normalized } }, true)
+    await select(conn, true)
   }
 
   async function handleEdit(original: ServerConnection.Any, value: string) {
@@ -330,53 +383,115 @@ export function DialogSelectServer() {
       return
     }
 
-    if (normalized === original.http.url) {
+    const name = store.editServer.name.trim() || undefined
+    const username = store.editServer.username || undefined
+    const password = store.editServer.password || undefined
+    const existingName = original.displayName
+    if (
+      normalized === original.http.url &&
+      name === existingName &&
+      username === original.http.username &&
+      password === original.http.password
+    ) {
       resetEdit()
       return
     }
 
     setStore("editServer", { busy: true, error: "" })
 
-    const result = await checkServerHealth({ url: normalized }, fetcher)
+    const conn: ServerConnection.Http = {
+      type: "http",
+      displayName: name,
+      http: { url: normalized, username, password },
+    }
+    const result = await checkServerHealth(conn.http, fetcher)
     setStore("editServer", { busy: false })
-
     if (!result.healthy) {
       setStore("editServer", { error: language.t("dialog.server.add.error") })
       return
     }
+    if (normalized === original.http.url) {
+      server.add(conn)
+    } else {
+      replaceServer(original, conn)
+    }
+
+    resetEdit()
+  }
+
+  const mode = createMemo<"list" | "add" | "edit">(() => {
+    if (store.editServer.id) return "edit"
+    if (store.addServer.showForm) return "add"
+    return "list"
+  })
 
-    replaceServer(original, normalized)
+  const editing = createMemo(() => {
+    if (!store.editServer.id) return
+    return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
+  })
 
+  const resetForm = () => {
+    resetAdd()
     resetEdit()
   }
 
-  const handleAddKey = (event: KeyboardEvent) => {
-    event.stopPropagation()
-    if (event.key !== "Enter" || event.isComposing) return
-    event.preventDefault()
-    handleAdd(store.addServer.url)
+  const startAdd = () => {
+    resetEdit()
+    setStore("addServer", {
+      showForm: true,
+      url: "",
+      name: "",
+      username: "",
+      password: "",
+      error: "",
+      status: undefined,
+    })
   }
 
-  const blurAdd = () => {
-    if (!store.addServer.url.trim()) {
-      resetAdd()
-      return
-    }
-    handleAdd(store.addServer.url)
+  const startEdit = (conn: ServerConnection.Http) => {
+    resetAdd()
+    setStore("editServer", {
+      id: conn.http.url,
+      value: conn.http.url,
+      name: conn.displayName ?? "",
+      username: conn.http.username ?? "",
+      password: conn.http.password ?? "",
+      error: "",
+      status: store.status[ServerConnection.key(conn)]?.healthy,
+      busy: false,
+    })
   }
 
-  const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
-    event.stopPropagation()
-    if (event.key === "Escape") {
-      event.preventDefault()
-      resetEdit()
+  const submitForm = () => {
+    if (mode() === "add") {
+      void handleAdd(store.addServer.url)
       return
     }
-    if (event.key !== "Enter" || event.isComposing) return
-    event.preventDefault()
-    handleEdit(original, store.editServer.value)
+    const original = editing()
+    if (!original) return
+    void handleEdit(original, store.editServer.value)
   }
 
+  const isFormMode = createMemo(() => mode() !== "list")
+  const isAddMode = createMemo(() => mode() === "add")
+  const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
+
+  const formTitle = createMemo(() => {
+    if (!isFormMode()) return language.t("dialog.server.title")
+    return (
+      <div class="flex items-center gap-2 -ml-2">
+        <IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
+        <span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
+      </div>
+    )
+  })
+
+  createEffect(() => {
+    if (!store.editServer.id) return
+    if (editing()) return
+    resetEdit()
+  })
+
   async function handleRemove(url: ServerConnection.Key) {
     server.remove(url)
     if ((await platform.getDefaultServerUrl?.()) === url) {
@@ -385,9 +500,29 @@ export function DialogSelectServer() {
   }
 
   return (
-    <Dialog title={language.t("dialog.server.title")}>
+    <Dialog title={formTitle()}>
       <div class="flex flex-col gap-2">
-        <div ref={(el) => (listRoot = el)}>
+        <Show
+          when={!isFormMode()}
+          fallback={
+            <ServerForm
+              value={isAddMode() ? store.addServer.url : store.editServer.value}
+              name={isAddMode() ? store.addServer.name : store.editServer.name}
+              username={isAddMode() ? store.addServer.username : store.editServer.username}
+              password={isAddMode() ? store.addServer.password : store.editServer.password}
+              placeholder={language.t("dialog.server.add.placeholder")}
+              busy={formBusy()}
+              error={isAddMode() ? store.addServer.error : store.editServer.error}
+              status={isAddMode() ? store.addServer.status : store.editServer.status}
+              onChange={isAddMode() ? handleAddChange : handleEditChange}
+              onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
+              onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
+              onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
+              onSubmit={submitForm}
+              onBack={resetForm}
+            />
+          }
+        >
           <List
             search={{
               placeholder: language.t("dialog.server.search.placeholder"),
@@ -400,143 +535,110 @@ export function DialogSelectServer() {
             onSelect={(x) => {
               if (x) select(x)
             }}
-            onFilter={(value) => {
-              if (value && store.addServer.showForm && !store.addServer.adding) {
-                resetAdd()
-              }
-            }}
             divider={true}
-            class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
-            add={
-              store.addServer.showForm
-                ? {
-                    render: () => (
-                      <AddRow
-                        value={store.addServer.url}
-                        placeholder={language.t("dialog.server.add.placeholder")}
-                        adding={store.addServer.adding}
-                        error={store.addServer.error}
-                        status={store.addServer.status}
-                        onChange={handleAddChange}
-                        onKeyDown={handleAddKey}
-                        onBlur={blurAdd}
-                      />
-                    ),
-                  }
-                : undefined
-            }
+            class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
           >
             {(i) => {
               const key = ServerConnection.key(i)
               return (
-                <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
-                  <Show
-                    when={store.editServer.id !== i.http.url}
-                    fallback={
-                      <EditRow
-                        value={store.editServer.value}
-                        placeholder={language.t("dialog.server.add.placeholder")}
-                        busy={store.editServer.busy}
-                        error={store.editServer.error}
-                        status={store.editServer.status}
-                        onChange={handleEditChange}
-                        onKeyDown={(event) => handleEditKey(event, i)}
-                        onBlur={() => handleEdit(i, store.editServer.value)}
-                      />
-                    }
-                  >
-                    <ServerRow
-                      conn={i}
-                      status={store.status[key]}
-                      dimmed={store.status[key]?.healthy === false}
-                      class="flex items-center gap-3 px-4 min-w-0 flex-1"
-                      badge={
-                        <Show when={defaultUrl() === i.http.url}>
-                          <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
-                            {language.t("dialog.server.status.default")}
-                          </span>
-                        </Show>
-                      }
-                    />
-                  </Show>
-                  <Show when={store.editServer.id !== i.http.url}>
-                    <div class="flex items-center justify-center gap-5 pl-4">
-                      <Show when={ServerConnection.key(current()) === key}>
-                        <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
+                <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
+                  <div class="flex flex-col h-full items-start w-5">
+                    <ServerHealthIndicator health={store.status[key]} />
+                  </div>
+                  <ServerRow
+                    conn={i}
+                    dimmed={store.status[key]?.healthy === false}
+                    status={store.status[key]}
+                    class="flex items-center gap-3 min-w-0 flex-1"
+                    badge={
+                      <Show when={defaultUrl() === i.http.url}>
+                        <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
+                          {language.t("dialog.server.status.default")}
+                        </span>
                       </Show>
-
-                      <Show when={i.type === "http"}>
-                        <DropdownMenu>
-                          <DropdownMenu.Trigger
-                            as={IconButton}
-                            icon="dot-grid"
-                            variant="ghost"
-                            class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
-                            onClick={(e: MouseEvent) => e.stopPropagation()}
-                            onPointerDown={(e: PointerEvent) => e.stopPropagation()}
-                          />
-                          <DropdownMenu.Portal>
-                            <DropdownMenu.Content class="mt-1">
-                              <DropdownMenu.Item
-                                onSelect={() => {
-                                  setStore("editServer", {
-                                    id: i.http.url,
-                                    value: i.http.url,
-                                    error: "",
-                                    status: store.status[ServerConnection.key(i)]?.healthy,
-                                  })
-                                }}
-                              >
-                                <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
+                    }
+                    showCredentials
+                  />
+                  <div class="flex items-center justify-center gap-4 pl-4">
+                    <Show when={ServerConnection.key(current()) === key}>
+                      <Icon name="check" class="h-6" />
+                    </Show>
+
+                    <Show when={i.type === "http"}>
+                      <DropdownMenu>
+                        <DropdownMenu.Trigger
+                          as={IconButton}
+                          icon="dot-grid"
+                          variant="ghost"
+                          class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
+                          onClick={(e: MouseEvent) => e.stopPropagation()}
+                          onPointerDown={(e: PointerEvent) => e.stopPropagation()}
+                        />
+                        <DropdownMenu.Portal>
+                          <DropdownMenu.Content class="mt-1">
+                            <DropdownMenu.Item
+                              onSelect={() => {
+                                if (i.type !== "http") return
+                                startEdit(i)
+                              }}
+                            >
+                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                            <Show when={canDefault() && defaultUrl() !== i.http.url}>
+                              <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
+                                <DropdownMenu.ItemLabel>
+                                  {language.t("dialog.server.menu.default")}
+                                </DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
-                              <Show when={canDefault() && defaultUrl() !== i.http.url}>
-                                <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
-                                  <DropdownMenu.ItemLabel>
-                                    {language.t("dialog.server.menu.default")}
-                                  </DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
-                              </Show>
-                              <Show when={canDefault() && defaultUrl() === i.http.url}>
-                                <DropdownMenu.Item onSelect={() => setDefault(null)}>
-                                  <DropdownMenu.ItemLabel>
-                                    {language.t("dialog.server.menu.defaultRemove")}
-                                  </DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
-                              </Show>
-                              <DropdownMenu.Separator />
-                              <DropdownMenu.Item
-                                onSelect={() => handleRemove(ServerConnection.key(i))}
-                                class="text-text-on-critical-base hover:bg-surface-critical-weak"
-                              >
+                            </Show>
+                            <Show when={canDefault() && defaultUrl() === i.http.url}>
+                              <DropdownMenu.Item onSelect={() => setDefault(null)}>
                                 <DropdownMenu.ItemLabel>
-                                  {language.t("dialog.server.menu.delete")}
+                                  {language.t("dialog.server.menu.defaultRemove")}
                                 </DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
-                            </DropdownMenu.Content>
-                          </DropdownMenu.Portal>
-                        </DropdownMenu>
-                      </Show>
-                    </div>
-                  </Show>
+                            </Show>
+                            <DropdownMenu.Separator />
+                            <DropdownMenu.Item
+                              onSelect={() => handleRemove(ServerConnection.key(i))}
+                              class="text-text-on-critical-base hover:bg-surface-critical-weak"
+                            >
+                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                          </DropdownMenu.Content>
+                        </DropdownMenu.Portal>
+                      </DropdownMenu>
+                    </Show>
+                  </div>
                 </div>
               )
             }}
           </List>
-        </div>
+        </Show>
 
         <div class="px-5 pb-5">
-          <Button
-            variant="secondary"
-            icon="plus-small"
-            size="large"
-            onClick={() => {
-              setStore("addServer", { showForm: true, url: "", error: "" })
-              scrollListToBottom()
-            }}
-            class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
+          <Show
+            when={isFormMode()}
+            fallback={
+              <Button
+                variant="secondary"
+                icon="plus-small"
+                size="large"
+                onClick={startAdd}
+                class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
+              >
+                {language.t("dialog.server.add.button")}
+              </Button>
+            }
           >
-            {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
-          </Button>
+            <Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
+              {formBusy()
+                ? language.t("dialog.server.add.checking")
+                : isAddMode()
+                  ? language.t("dialog.server.add.button")
+                  : language.t("common.save")}
+            </Button>
+          </Show>
         </div>
       </div>
     </Dialog>

+ 60 - 22
packages/app/src/components/server/server-row.tsx

@@ -1,5 +1,6 @@
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import {
+  children,
   createEffect,
   createMemo,
   createSignal,
@@ -9,7 +10,7 @@ import {
   type ParentProps,
   Show,
 } from "solid-js"
-import { type ServerConnection, serverDisplayName } from "@/context/server"
+import { type ServerConnection, serverName } from "@/context/server"
 import type { ServerHealth } from "@/utils/server-health"
 
 interface ServerRowProps extends ParentProps {
@@ -20,13 +21,14 @@ interface ServerRowProps extends ParentProps {
   versionClass?: string
   dimmed?: boolean
   badge?: JSXElement
+  showCredentials?: boolean
 }
 
 export function ServerRow(props: ServerRowProps) {
   const [truncated, setTruncated] = createSignal(false)
   let nameRef: HTMLSpanElement | undefined
   let versionRef: HTMLSpanElement | undefined
-  const name = createMemo(() => serverDisplayName(props.conn))
+  const name = createMemo(() => serverName(props.conn))
 
   const check = () => {
     const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -52,35 +54,71 @@ export function ServerRow(props: ServerRowProps) {
 
   const tooltipValue = () => (
     <span class="flex items-center gap-2">
-      <span>{name()}</span>
+      <span>{serverName(props.conn, true)}</span>
       <Show when={props.status?.version}>
-        <span class="text-text-invert-base">{props.status?.version}</span>
+        <span class="text-text-invert-weak">v{props.status?.version}</span>
       </Show>
     </span>
   )
 
+  const badge = children(() => props.badge)
+
   return (
-    <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
+    <Tooltip
+      class="flex-1"
+      value={tooltipValue()}
+      placement="top-start"
+      inactive={!truncated() && !props.conn.displayName}
+    >
       <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
-        <div
-          classList={{
-            "size-1.5 rounded-full shrink-0": true,
-            "bg-icon-success-base": props.status?.healthy === true,
-            "bg-icon-critical-base": props.status?.healthy === false,
-            "bg-border-weak-base": props.status === undefined,
-          }}
-        />
-        <span ref={nameRef} class={props.nameClass ?? "truncate"}>
-          {name()}
-        </span>
-        <Show when={props.status?.version}>
-          <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
-            {props.status?.version}
-          </span>
-        </Show>
-        {props.badge}
+        <div class="flex flex-col items-start">
+          <div class="flex flex-row items-center gap-2">
+            <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+              {name()}
+            </span>
+            <Show
+              when={badge()}
+              fallback={
+                <Show when={props.status?.version}>
+                  <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+                    v{props.status?.version}
+                  </span>
+                </Show>
+              }
+            >
+              {(badge) => badge()}
+            </Show>
+          </div>
+          <Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
+            {(conn) => (
+              <div class="flex flex-row gap-3">
+                <span>
+                  {conn().http.username ? (
+                    <span class="text-text-weak">{conn().http.username}</span>
+                  ) : (
+                    <span class="text-text-weaker">no username</span>
+                  )}
+                </span>
+                {conn().http.password && <span class="text-text-weak">••••••••</span>}
+              </div>
+            )}
+          </Show>
+        </div>
         {props.children}
       </div>
     </Tooltip>
   )
 }
+
+export function ServerHealthIndicator(props: { health?: ServerHealth }) {
+  return (
+    <div
+      classList={{
+        "size-1.5 rounded-full shrink-0": true,
+        "bg-icon-success-base": props.health?.healthy === true,
+        "bg-icon-critical-base": props.health?.healthy === false,
+        "bg-border-weak-base": props.health === undefined,
+      }}
+    />
+  )
+}

+ 3 - 2
packages/app/src/components/status-popover.tsx

@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
-import { ServerRow } from "@/components/server/server-row"
+import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
@@ -276,10 +276,11 @@ export function StatusPopover() {
                           navigate("/")
                         }}
                       >
+                        <ServerHealthIndicator health={health[key]} />
                         <ServerRow
                           conn={s}
-                          status={health[key]}
                           dimmed={isBlocked()}
+                          status={health[key]}
                           class="flex items-center gap-2 w-full min-w-0"
                           nameClass="text-14-regular text-text-base truncate"
                           versionClass="text-12-regular text-text-weak truncate"

+ 32 - 18
packages/app/src/context/server.tsx

@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
 import { checkServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
+type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
 const HEALTH_POLL_INTERVAL_MS = 10_000
 
 export function normalizeServerUrl(input: string) {
@@ -15,9 +16,9 @@ export function normalizeServerUrl(input: string) {
   return withProtocol.replace(/\/+$/, "")
 }
 
-export function serverDisplayName(conn?: ServerConnection.Any) {
+export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
   if (!conn) return ""
-  if (conn.displayName) return conn.displayName
+  if (conn.displayName && !ignoreDisplayName) return conn.displayName
   return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
 }
 
@@ -100,22 +101,33 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
     const [store, setStore, _, ready] = persisted(
       Persist.global("server", ["server.v3"]),
       createStore({
-        list: [] as string[],
+        list: [] as StoredServer[],
         projects: {} as Record<string, StoredProject[]>,
         lastProject: {} as Record<string, string>,
       }),
     )
 
+    const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
+
     const allServers = createMemo((): Array<ServerConnection.Any> => {
       const servers = [
         ...(props.servers ?? []),
-        ...store.list.map((value) => ({
-          type: "http" as const,
-          http: typeof value === "string" ? { url: value } : value,
-        })),
+        ...store.list.map((value) =>
+          typeof value === "string"
+            ? {
+                type: "http" as const,
+                http: { url: value },
+              }
+            : value,
+        ),
       ]
 
-      const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
+      const deduped = new Map(
+        servers.map((value) => {
+          const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
+          return [ServerConnection.key(conn), conn]
+        }),
+      )
 
       return [...deduped.values()]
     })
@@ -156,27 +168,29 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       if (state.active !== input) setState("active", input)
     }
 
-    function add(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
+    function add(input: ServerConnection.Http) {
+      const url_ = normalizeServerUrl(input.http.url)
+      if (!url_) return
+      const conn = { ...input, http: { ...input.http, url: url_ } }
       return batch(() => {
-        const http: ServerConnection.HttpBase = { url }
-        if (!store.list.includes(url)) {
-          setStore("list", store.list.length, url)
+        const existing = store.list.findIndex((x) => url(x) === url_)
+        if (existing !== -1) {
+          setStore("list", existing, conn)
+        } else {
+          setStore("list", store.list.length, conn)
         }
-        const conn: ServerConnection.Http = { type: "http", http }
         setState("active", ServerConnection.key(conn))
         return conn
       })
     }
 
     function remove(key: ServerConnection.Key) {
-      const list = store.list.filter((x) => x !== key)
+      const list = store.list.filter((x) => url(x) !== key)
       batch(() => {
         setStore("list", list)
         if (state.active === key) {
           const next = list[0]
-          setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
+          setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
         }
       })
     }
@@ -212,7 +226,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
         return state.active
       },
       get name() {
-        return serverDisplayName(current())
+        return serverName(current())
       },
       get list() {
         return allServers()

+ 7 - 2
packages/app/src/i18n/en.ts

@@ -309,12 +309,17 @@ export const dict = {
   "dialog.server.description": "Switch which OpenCode server this app connects to.",
   "dialog.server.search.placeholder": "Search servers",
   "dialog.server.empty": "No servers yet",
-  "dialog.server.add.title": "Add a server",
-  "dialog.server.add.url": "Server URL",
+  "dialog.server.add.title": "Add server",
+  "dialog.server.add.url": "Server address",
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Could not connect to server",
   "dialog.server.add.checking": "Checking...",
   "dialog.server.add.button": "Add server",
+  "dialog.server.add.name": "Server name (optional)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "Username (optional)",
+  "dialog.server.add.password": "Password (optional)",
+  "dialog.server.edit.title": "Edit server",
   "dialog.server.default.title": "Default server",
   "dialog.server.default.description":
     "Connect to this server on app launch instead of starting a local server. Requires restart.",

+ 5 - 6
packages/console/app/src/routes/zen/util/handler.ts

@@ -245,16 +245,15 @@ export async function handler(
                 dataDumper?.flush()
                 await rateLimiter?.track()
                 const usage = usageParser.retrieve()
-                let cost = "0"
                 if (usage) {
                   const usageInfo = providerInfo.normalizeUsage(usage)
                   const costInfo = calculateCost(modelInfo, usageInfo)
                   await trialLimiter?.track(usageInfo)
                   await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
                   await reload(billingSource, authInfo, costInfo)
-                  cost = calculateOccuredCost(billingSource, costInfo)
+                  const cost = calculateOccuredCost(billingSource, costInfo)
+                  c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
                 }
-                c.enqueue(encoder.encode(usageParser.buidlCostChunk(cost)))
                 c.close()
                 return
               }
@@ -654,12 +653,12 @@ export async function handler(
         }
 
         // Check rolling limit
-        if (sub.monthlyUsage && sub.timeMonthlyUpdated) {
+        if (sub.rollingUsage && sub.timeRollingUpdated) {
           const result = Subscription.analyzeRollingUsage({
             limit: liteData.rollingLimit,
             window: liteData.rollingWindow,
-            usage: sub.monthlyUsage,
-            timeUpdated: sub.timeMonthlyUpdated,
+            usage: sub.rollingUsage,
+            timeUpdated: sub.timeRollingUpdated,
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(

+ 21 - 4
packages/desktop/src-tauri/src/lib.rs

@@ -181,14 +181,31 @@ fn resolve_app_path(app_name: &str) -> Option<String> {
 
 #[tauri::command]
 #[specta::specta]
-fn open_in_powershell(path: String) -> Result<(), String> {
+fn open_path(_app: AppHandle, path: String, app_name: Option<String>) -> Result<(), String> {
     #[cfg(target_os = "windows")]
     {
-        return os::windows::open_in_powershell(path);
+        let app_name = app_name.map(|v| os::windows::resolve_windows_app_path(&v).unwrap_or(v));
+        let is_powershell = app_name.as_ref().is_some_and(|v| {
+            std::path::Path::new(v)
+                .file_name()
+                .and_then(|name| name.to_str())
+                .is_some_and(|name| {
+                    name.eq_ignore_ascii_case("powershell")
+                        || name.eq_ignore_ascii_case("powershell.exe")
+                })
+        });
+
+        if is_powershell {
+            return os::windows::open_in_powershell(path);
+        }
+
+        return tauri_plugin_opener::open_path(path, app_name.as_deref())
+            .map_err(|e| format!("Failed to open path: {e}"));
     }
 
     #[cfg(not(target_os = "windows"))]
-    Err("PowerShell is only supported on Windows".to_string())
+    tauri_plugin_opener::open_path(path, app_name.as_deref())
+        .map_err(|e| format!("Failed to open path: {e}"))
 }
 
 #[cfg(target_os = "macos")]
@@ -386,7 +403,7 @@ fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
             check_app_exists,
             wsl_path,
             resolve_app_path,
-            open_in_powershell
+            open_path
         ])
         .events(tauri_specta::collect_events![
             LoadingWindowComplete,

+ 1 - 1
packages/desktop/src/bindings.ts

@@ -18,7 +18,7 @@ export const commands = {
 	checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
 	wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
 	resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
-	openInPowershell: (path: string) => __TAURI_INVOKE<null>("open_in_powershell", { path }),
+	openPath: (path: string, appName: string | null) => __TAURI_INVOKE<null>("open_path", { path, appName }),
 };
 
 /** Events */

+ 1 - 24
packages/desktop/src/index.tsx

@@ -17,7 +17,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
 import { open, save } from "@tauri-apps/plugin-dialog"
 import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
 import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
-import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
 import { type as ostype } from "@tauri-apps/plugin-os"
 import { relaunch } from "@tauri-apps/plugin-process"
 import { open as shellOpen } from "@tauri-apps/plugin-shell"
@@ -116,29 +115,7 @@ const createPlatform = (): Platform => {
       void shellOpen(url).catch(() => undefined)
     },
     async openPath(path: string, app?: string) {
-      const os = ostype()
-      if (os === "windows") {
-        const resolvedPath = await (async () => {
-          if (window.__OPENCODE__?.wsl) {
-            const converted = await commands.wslPath(path, "windows").catch(() => null)
-            if (converted) return converted
-          }
-
-          return path
-        })()
-        const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
-        const isPowershell = (value?: string) => {
-          if (!value) return false
-          const name = value.toLowerCase().replaceAll("/", "\\").split("\\").pop()
-          return name === "powershell" || name === "powershell.exe"
-        }
-        if (isPowershell(resolvedApp)) {
-          await commands.openInPowershell(resolvedPath)
-          return
-        }
-        return openerOpenPath(resolvedPath, resolvedApp)
-      }
-      return openerOpenPath(path, app)
+      await commands.openPath(path, app ?? null)
     },
 
     back() {

+ 1 - 1
packages/ui/src/components/dialog.css

@@ -54,7 +54,7 @@
 
       [data-slot="dialog-header"] {
         display: flex;
-        padding: 20px;
+        padding: 16px 20px;
         justify-content: space-between;
         align-items: center;
         flex-shrink: 0;