diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 90a644097b3d..cc63c82450c0 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -189,7 +189,7 @@ export function DialogSelectServer() { ) } -export function useServerManagementController(options: { onSelect?: () => void } = {}) { +export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { const navigate = useNavigate() const server = useServer() const tabs = useTabs() @@ -265,6 +265,11 @@ export function useServerManagementController(options: { onSelect?: () => void } } resetAdd() + if (options.navigateOnAdd === false) { + server.add(conn) + options.onSelect?.() + return + } await select(conn, true) }, })) diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx new file mode 100644 index 000000000000..4b420d7ca967 --- /dev/null +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -0,0 +1,65 @@ +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { type Component, Show } from "solid-js" +import { useServerManagementController } from "@/components/dialog-select-server" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" + +export const ServerRowMenu: Component<{ + server: ServerConnection.Any + controller: ReturnType + onEdit: (server: ServerConnection.Http) => void + open?: boolean + onOpenChange?: (open: boolean) => void +}> = (props) => { + const language = useLanguage() + const key = ServerConnection.key(props.server) + const builtin = ServerConnection.builtin(props.server) + const isDefault = () => props.controller.defaultKey() === key + + return ( + + } + aria-label={language.t("common.moreOptions")} + /> + + + + {language.t("settings.section.server")} + props.onEdit(props.server as ServerConnection.Http)} + > + {language.t("dialog.server.menu.edit")} + + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + + + + props.controller.setDefault(null)}> + {language.t("dialog.server.menu.defaultRemove")} + + + + props.controller.handleRemove(key)}> + {language.t("dialog.server.menu.delete")} + + + + + + ) +} diff --git a/packages/app/src/components/settings-v2/dialog-server-v2.tsx b/packages/app/src/components/settings-v2/dialog-server-v2.tsx new file mode 100644 index 000000000000..bad1c9ab1a8b --- /dev/null +++ b/packages/app/src/components/settings-v2/dialog-server-v2.tsx @@ -0,0 +1,129 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { useLanguage } from "@/context/language" +import { type ServerConnection } from "@/context/server" +import { useServerManagementController } from "../dialog-select-server" +import "./settings-v2.css" + +export const DialogServerV2: Component<{ + mode: "add" | "edit" + server?: ServerConnection.Http +}> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const controller = useServerManagementController({ + onSelect: () => dialog.close(), + navigateOnAdd: false, + }) + const [opened, setOpened] = createSignal(false) + + onMount(() => { + if (props.mode === "add") controller.startAdd() + if (props.mode === "edit" && props.server) controller.startEdit(props.server) + setOpened(true) + }) + + onCleanup(() => { + controller.resetForm() + }) + + createEffect(() => { + if (!opened()) return + if (controller.isFormMode()) return + dialog.close() + }) + + const keyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + controller.submitForm() + } + + const title = () => + props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") + + const submitLabel = () => { + if (controller.formBusy()) return language.t("dialog.server.add.checking") + if (props.mode === "add") return language.t("dialog.server.add.button") + return language.t("common.save") + } + + return ( + +
+
+
+ + controller.handleFormChange()(event.currentTarget.value)} + onKeyDown={keyDown} + /> + + {controller.formError()} + +
+
+ + controller.handleFormNameChange()(event.currentTarget.value)} + onKeyDown={keyDown} + /> +
+
+
+ + controller.handleFormUsernameChange()(event.currentTarget.value)} + onKeyDown={keyDown} + /> +
+
+ + controller.handleFormPasswordChange()(event.currentTarget.value)} + onKeyDown={keyDown} + /> +
+
+
+
+ + dialog.close()}> + {language.t("common.cancel")} + + + {submitLabel()} + + +
+ ) +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 932f2dedd468..cfe6971b12b6 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds" import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" -import { SettingsServers } from "../settings-servers" +import { SettingsServersV2 } from "./servers" export const DialogSettings: Component = () => { const language = useLanguage() @@ -33,16 +33,16 @@ export const DialogSettings: Component = () => { {language.t("settings.tab.shortcuts")} - - - {language.t("status.popover.tab.servers")} -
{language.t("settings.section.server")}
+ + + {language.t("status.popover.tab.servers")} + {language.t("settings.providers.title")} @@ -68,7 +68,7 @@ export const DialogSettings: Component = () => { - + diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx new file mode 100644 index 000000000000..8669d2f5d4f1 --- /dev/null +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -0,0 +1,139 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import fuzzysort from "fuzzysort" +import { type Component, For, Show, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { ServerRowMenu } from "@/components/server/server-row-menu" +import { ServerHealthIndicator } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { ServerConnection, serverName } from "@/context/server" +import { useServerManagementController } from "../dialog-select-server" +import { DialogServerV2 } from "./dialog-server-v2" +import { SettingsListV2 } from "./parts/list" +import "./settings-v2.css" + +export const SettingsServersV2: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const controller = useServerManagementController() + const [store, setStore] = createStore({ filter: "" }) + + const showSearch = createMemo(() => controller.sortedItems().length > 1) + + const filtered = createMemo(() => { + const items = controller.sortedItems() + const query = store.filter.trim() + if (!query) return items + return fuzzysort + .go(query, items, { + keys: [(item) => serverName(item), (item) => item.http.url], + }) + .map((result) => result.obj) + }) + + const openAdd = () => { + dialog.push(() => ) + } + + const openEdit = (server: ServerConnection.Http) => { + dialog.push(() => ) + } + + return ( + <> +
+
+

{language.t("status.popover.tab.servers")}

+ + {language.t("dialog.server.add.button")} + +
+ + + +
+ +
+ 0} + fallback={ +
+ {store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")} + + "{store.filter}" + +
+ } + > + + + {(item) => { + const key = ServerConnection.key(item) + const health = () => controller.status()[key] + const isDefault = () => controller.defaultKey() === key + return ( +
+
+ +
+ {serverName(item)} + + v{health()?.version} + + {language.t("server.row.noUsername")} + } + > + {item.http.username} + + +
+
+
+ + {language.t("dialog.server.status.default")} + + +
+
+ ) + }} +
+
+
+
+ + ) +} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 38b6733621ee..864adc27f6ec 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -511,3 +511,144 @@ .settings-v2-shortcuts-status-filter { color: var(--v2-text-text-base); } + +.settings-v2-tab-body.settings-v2-servers { + gap: 0; +} + +.settings-v2-tab-header.settings-v2-servers-header { + padding-bottom: 24px; +} + +.settings-v2-servers-header .settings-v2-tab-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked { + gap: 24px; + padding-bottom: 24px; +} + +.settings-v2-servers [data-component="settings-v2-list"] { + display: flex; + flex-direction: column; + gap: 0; + padding: 20px; + border-radius: 6px; +} + +.settings-v2-servers-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.settings-v2-servers-row:not(:last-child) { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +.settings-v2-servers-actions { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.settings-v2-servers-lead { + display: flex; + min-width: 0; + flex: 1; + align-items: flex-start; + gap: 10px; +} + +.settings-v2-servers-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; +} + +.settings-v2-servers-name { + font-size: 13px; + font-weight: 530; + line-height: 1; + color: var(--v2-text-text-base); +} + +.settings-v2-servers-meta { + font-size: 11px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +.settings-v2-servers-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding-block: 48px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); + text-align: center; +} + +.settings-v2-servers-status-filter { + color: var(--v2-text-text-base); +} + +[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { + width: 480px; + max-width: calc(100vw - 32px); + height: auto; + border-radius: 8px; + align-items: stretch; +} + +[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] { + align-items: stretch; + width: 100%; +} + +[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] { + align-items: center; + padding: 24px 24px 0; +} + +[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] { + display: flex; + width: 100%; + min-width: 0; + flex-direction: column; + align-items: stretch; +} + +[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] { + padding: 24px; +} + +.settings-v2-server-dialog-label { + font-size: 13px; + font-weight: 530; + line-height: 1; + color: var(--v2-text-text-base); +} + +.settings-v2-server-dialog-error { + font-size: 11px; + font-weight: 440; + line-height: 1; + color: var(--v2-state-fg-danger); +} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index cc8e30b53de5..a0fec02c07b4 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -202,6 +202,8 @@ export namespace ServerConnection { export type Key = string & { _brand: "Key" } export const Key = { make: (v: string) => v as Key } + + export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base" } export const { use: useServer, provider: ServerProvider } = createSimpleContext({ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index b15e528a1ad9..7b40d12b80ab 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -20,7 +20,8 @@ import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogSelectServer } from "@/components/dialog-select-server" +import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" +import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { ServerConnection, useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -45,7 +46,9 @@ import { sessionPermissionRequest } from "@/pages/session/composer/session-reque import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" import { useSettings } from "@/context/settings" +import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" +import { type ServerHealth } from "@/utils/server-health" const HOME_SESSION_LIMIT = 15 const HOME_ROW_LAYOUT = @@ -497,6 +500,8 @@ function HomeProjectColumn(props: { language: ReturnType }) { const global = useGlobal() + const dialog = useDialog() + const controller = useServerManagementController({ navigateOnAdd: false }) return (