Visitar URL original
feat(app): improve servers UI (#30961) · BenGu3/opencode@8b4d775 · GitHub
Skip to content

Commit 8b4d775

Browse files
arvsrnBenGu3
authored andcommitted
feat(app): improve servers UI (anomalyco#30961)
1 parent d1da66a commit 8b4d775

10 files changed

Lines changed: 628 additions & 62 deletions

File tree

‎packages/app/src/components/dialog-select-server.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export function DialogSelectServer() {
189189
)
190190
}
191191

192-
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
192+
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
193193
const navigate = useNavigate()
194194
const server = useServer()
195195
const tabs = useTabs()
@@ -265,6 +265,11 @@ export function useServerManagementController(options: { onSelect?: () => void }
265265
}
266266

267267
resetAdd()
268+
if (options.navigateOnAdd === false) {
269+
server.add(conn)
270+
options.onSelect?.()
271+
return
272+
}
268273
await select(conn, true)
269274
},
270275
}))
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
2+
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
3+
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
4+
import { type Component, Show } from "solid-js"
5+
import { useServerManagementController } from "@/components/dialog-select-server"
6+
import { useLanguage } from "@/context/language"
7+
import { ServerConnection } from "@/context/server"
8+
9+
export const ServerRowMenu: Component<{
10+
server: ServerConnection.Any
11+
controller: ReturnType<typeof useServerManagementController>
12+
onEdit: (server: ServerConnection.Http) => void
13+
open?: boolean
14+
onOpenChange?: (open: boolean) => void
15+
}> = (props) => {
16+
const language = useLanguage()
17+
const key = ServerConnection.key(props.server)
18+
const builtin = ServerConnection.builtin(props.server)
19+
const isDefault = () => props.controller.defaultKey() === key
20+
21+
return (
22+
<MenuV2
23+
gutter={4}
24+
modal={false}
25+
placement="bottom-end"
26+
open={props.open}
27+
onOpenChange={props.onOpenChange}
28+
>
29+
<MenuV2.Trigger
30+
as={IconButtonV2}
31+
variant="ghost-muted"
32+
size="small"
33+
icon={<IconV2 name="outline-dots" />}
34+
aria-label={language.t("common.moreOptions")}
35+
/>
36+
<MenuV2.Portal>
37+
<MenuV2.Content>
38+
<MenuV2.Group>
39+
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
40+
<MenuV2.Item
41+
disabled={builtin || props.server.type !== "http"}
42+
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
43+
>
44+
{language.t("dialog.server.menu.edit")}
45+
</MenuV2.Item>
46+
<Show when={props.controller.canDefault() && !isDefault()}>
47+
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
48+
{language.t("dialog.server.menu.default")}
49+
</MenuV2.Item>
50+
</Show>
51+
<Show when={props.controller.canDefault() && isDefault()}>
52+
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
53+
{language.t("dialog.server.menu.defaultRemove")}
54+
</MenuV2.Item>
55+
</Show>
56+
<MenuV2.Separator />
57+
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
58+
{language.t("dialog.server.menu.delete")}
59+
</MenuV2.Item>
60+
</MenuV2.Group>
61+
</MenuV2.Content>
62+
</MenuV2.Portal>
63+
</MenuV2>
64+
)
65+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
2+
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
3+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
4+
import { useDialog } from "@opencode-ai/ui/context/dialog"
5+
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
6+
import { useLanguage } from "@/context/language"
7+
import { type ServerConnection } from "@/context/server"
8+
import { useServerManagementController } from "../dialog-select-server"
9+
import "./settings-v2.css"
10+
11+
export const DialogServerV2: Component<{
12+
mode: "add" | "edit"
13+
server?: ServerConnection.Http
14+
}> = (props) => {
15+
const dialog = useDialog()
16+
const language = useLanguage()
17+
const controller = useServerManagementController({
18+
onSelect: () => dialog.close(),
19+
navigateOnAdd: false,
20+
})
21+
const [opened, setOpened] = createSignal(false)
22+
23+
onMount(() => {
24+
if (props.mode === "add") controller.startAdd()
25+
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
26+
setOpened(true)
27+
})
28+
29+
onCleanup(() => {
30+
controller.resetForm()
31+
})
32+
33+
createEffect(() => {
34+
if (!opened()) return
35+
if (controller.isFormMode()) return
36+
dialog.close()
37+
})
38+
39+
const keyDown = (event: KeyboardEvent) => {
40+
if (event.key !== "Enter" || event.isComposing) return
41+
event.preventDefault()
42+
controller.submitForm()
43+
}
44+
45+
const title = () =>
46+
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
47+
48+
const submitLabel = () => {
49+
if (controller.formBusy()) return language.t("dialog.server.add.checking")
50+
if (props.mode === "add") return language.t("dialog.server.add.button")
51+
return language.t("common.save")
52+
}
53+
54+
return (
55+
<Dialog title={title()} fit class="settings-v2-server-dialog">
56+
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
57+
<div class="flex w-full min-w-0 flex-col gap-6">
58+
<div class="flex w-full min-w-0 flex-col gap-2">
59+
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
60+
<TextInputV2
61+
type="text"
62+
appearance="large"
63+
class="!w-full self-stretch"
64+
value={controller.formValue()}
65+
placeholder={language.t("dialog.server.add.placeholder")}
66+
invalid={!!controller.formError()}
67+
disabled={controller.formBusy()}
68+
autofocus
69+
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
70+
onKeyDown={keyDown}
71+
/>
72+
<Show when={controller.formError()}>
73+
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
74+
</Show>
75+
</div>
76+
<div class="flex w-full min-w-0 flex-col gap-2">
77+
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
78+
<TextInputV2
79+
type="text"
80+
appearance="large"
81+
class="!w-full self-stretch"
82+
value={controller.formName()}
83+
placeholder={language.t("dialog.server.add.namePlaceholder")}
84+
disabled={controller.formBusy()}
85+
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
86+
onKeyDown={keyDown}
87+
/>
88+
</div>
89+
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
90+
<div class="flex min-w-0 flex-col gap-2">
91+
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
92+
<TextInputV2
93+
type="text"
94+
appearance="large"
95+
class="!w-full self-stretch"
96+
value={controller.formUsername()}
97+
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
98+
disabled={controller.formBusy()}
99+
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
100+
onKeyDown={keyDown}
101+
/>
102+
</div>
103+
<div class="flex min-w-0 flex-col gap-2">
104+
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
105+
<TextInputV2
106+
type="password"
107+
appearance="large"
108+
class="!w-full self-stretch"
109+
value={controller.formPassword()}
110+
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
111+
disabled={controller.formBusy()}
112+
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
113+
onKeyDown={keyDown}
114+
/>
115+
</div>
116+
</div>
117+
</div>
118+
</div>
119+
<DialogFooter>
120+
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
121+
{language.t("common.cancel")}
122+
</ButtonV2>
123+
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
124+
{submitLabel()}
125+
</ButtonV2>
126+
</DialogFooter>
127+
</Dialog>
128+
)
129+
}

‎packages/app/src/components/settings-v2/dialog-settings-v2.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds"
99
import { SettingsProvidersV2 } from "./providers"
1010
import { SettingsModelsV2 } from "./models"
1111
import "./settings-v2.css"
12-
import { SettingsServers } from "../settings-servers"
12+
import { SettingsServersV2 } from "./servers"
1313

1414
export const DialogSettings: Component = () => {
1515
const language = useLanguage()
@@ -33,16 +33,16 @@ export const DialogSettings: Component = () => {
3333
<Icon name="keyboard" />
3434
{language.t("settings.tab.shortcuts")}
3535
</TabsV2.Trigger>
36-
<TabsV2.Trigger value="servers">
37-
<Icon name="server" />
38-
{language.t("status.popover.tab.servers")}
39-
</TabsV2.Trigger>
4036
</div>
4137
</div>
4238

4339
<div class="flex flex-col gap-1.5">
4440
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
4541
<div class="flex flex-col gap-1.5 w-full">
42+
<TabsV2.Trigger value="servers">
43+
<Icon name="server" />
44+
{language.t("status.popover.tab.servers")}
45+
</TabsV2.Trigger>
4646
<TabsV2.Trigger value="providers">
4747
<Icon name="providers" />
4848
{language.t("settings.providers.title")}
@@ -68,7 +68,7 @@ export const DialogSettings: Component = () => {
6868
<SettingsKeybinds v2 />
6969
</TabsV2.Content>
7070
<TabsV2.Content value="servers" class="settings-v2-panel">
71-
<SettingsServers />
71+
<SettingsServersV2 />
7272
</TabsV2.Content>
7373
<TabsV2.Content value="providers" class="settings-v2-panel">
7474
<SettingsProvidersV2 />
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
2+
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
3+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
4+
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
5+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
6+
import { useDialog } from "@opencode-ai/ui/context/dialog"
7+
import fuzzysort from "fuzzysort"
8+
import { type Component, For, Show, createMemo } from "solid-js"
9+
import { createStore } from "solid-js/store"
10+
import { ServerRowMenu } from "@/components/server/server-row-menu"
11+
import { ServerHealthIndicator } from "@/components/server/server-row"
12+
import { useLanguage } from "@/context/language"
13+
import { ServerConnection, serverName } from "@/context/server"
14+
import { useServerManagementController } from "../dialog-select-server"
15+
import { DialogServerV2 } from "./dialog-server-v2"
16+
import { SettingsListV2 } from "./parts/list"
17+
import "./settings-v2.css"
18+
19+
export const SettingsServersV2: Component = () => {
20+
const dialog = useDialog()
21+
const language = useLanguage()
22+
const controller = useServerManagementController()
23+
const [store, setStore] = createStore({ filter: "" })
24+
25+
const showSearch = createMemo(() => controller.sortedItems().length > 1)
26+
27+
const filtered = createMemo(() => {
28+
const items = controller.sortedItems()
29+
const query = store.filter.trim()
30+
if (!query) return items
31+
return fuzzysort
32+
.go(query, items, {
33+
keys: [(item) => serverName(item), (item) => item.http.url],
34+
})
35+
.map((result) => result.obj)
36+
})
37+
38+
const openAdd = () => {
39+
dialog.push(() => <DialogServerV2 mode="add" />)
40+
}
41+
42+
const openEdit = (server: ServerConnection.Http) => {
43+
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
44+
}
45+
46+
return (
47+
<>
48+
<div
49+
class="settings-v2-tab-header settings-v2-servers-header"
50+
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
51+
>
52+
<div class="settings-v2-tab-header-row">
53+
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
54+
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
55+
{language.t("dialog.server.add.button")}
56+
</ButtonV2>
57+
</div>
58+
<Show when={showSearch()}>
59+
<div class="settings-v2-tab-search">
60+
<TextInputV2
61+
type="search"
62+
appearance="base"
63+
value={store.filter}
64+
onInput={(event) => setStore("filter", event.currentTarget.value)}
65+
placeholder={language.t("dialog.server.search.placeholder")}
66+
spellcheck={false}
67+
autocorrect="off"
68+
autocomplete="off"
69+
autocapitalize="off"
70+
aria-label={language.t("dialog.server.search.placeholder")}
71+
/>
72+
<Show when={store.filter}>
73+
<IconButtonV2
74+
type="button"
75+
variant="ghost-muted"
76+
size="small"
77+
class="settings-v2-tab-search-clear"
78+
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
79+
onClick={() => setStore("filter", "")}
80+
/>
81+
</Show>
82+
</div>
83+
</Show>
84+
</div>
85+
86+
<div class="settings-v2-tab-body settings-v2-servers">
87+
<Show
88+
when={filtered().length > 0}
89+
fallback={
90+
<div class="settings-v2-servers-status">
91+
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
92+
<Show when={store.filter}>
93+
<span class="settings-v2-servers-status-filter">&quot;{store.filter}&quot;</span>
94+
</Show>
95+
</div>
96+
}
97+
>
98+
<SettingsListV2>
99+
<For each={filtered()}>
100+
{(item) => {
101+
const key = ServerConnection.key(item)
102+
const health = () => controller.status()[key]
103+
const isDefault = () => controller.defaultKey() === key
104+
return (
105+
<div class="settings-v2-servers-row">
106+
<div class="settings-v2-servers-lead">
107+
<ServerHealthIndicator health={health()} />
108+
<div class="settings-v2-servers-copy">
109+
<span class="settings-v2-servers-name">{serverName(item)}</span>
110+
<span class="settings-v2-servers-meta">
111+
<Show when={health()?.version}>v{health()?.version}</Show>
112+
<Show when={health()?.version && item.type === "http"}> • </Show>
113+
<Show
114+
when={item.type === "http" && item.http.username}
115+
fallback={
116+
<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>
117+
}
118+
>
119+
{item.http.username}
120+
</Show>
121+
</span>
122+
</div>
123+
</div>
124+
<div class="settings-v2-servers-actions">
125+
<Show when={controller.canDefault() && isDefault()}>
126+
<Tag>{language.t("dialog.server.status.default")}</Tag>
127+
</Show>
128+
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
129+
</div>
130+
</div>
131+
)
132+
}}
133+
</For>
134+
</SettingsListV2>
135+
</Show>
136+
</div>
137+
</>
138+
)
139+
}

0 commit comments

Comments
 (0)