feat(app): add servers tab to settings dialog

This commit is contained in:
Brendan Allan
2026-05-28 13:31:58 +08:00
parent e5524f5bf9
commit 3f501c7679
13 changed files with 192 additions and 145 deletions
@@ -277,6 +277,7 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">{select()?.message}</div> <div class="text-14-regular text-text-base">{select()?.message}</div>
<div> <div>
<List <List
class="px-3"
items={select()?.options ?? []} items={select()?.options ?? []}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
@@ -364,6 +365,7 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
<div> <div>
<List <List
class="px-3"
ref={(ref) => { ref={(ref) => {
listRef = ref listRef = ref
}} }}
+1 -1
View File
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
return ( return (
<Dialog title={language.t("command.session.fork")}> <Dialog title={language.t("command.session.fork")}>
<List <List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0" class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")} emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id} key={(x) => x.id}
@@ -39,6 +39,7 @@ export const DialogManageModels: Component = () => {
} }
> >
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`} key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -324,6 +324,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ( return (
<Dialog title={props.title ?? language.t("command.project.open")}> <Dialog title={props.title ?? language.t("command.project.open")}>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")} emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")} loadingMessage={language.t("common.loading")}
@@ -386,6 +386,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return ( return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition> <Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List <List
class="px-3"
search={{ search={{
placeholder: filesOnly() placeholder: filesOnly()
? language.t("session.header.searchFiles") ? language.t("session.header.searchFiles")
@@ -55,6 +55,7 @@ export const DialogSelectMcp: Component = () => {
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })} description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
> >
<List <List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")} emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""} key={(x) => x?.name ?? ""}
@@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)} ref={(ref) => (listRef = ref)}
items={model.list} items={model.list}
current={model.current()} current={model.current()}
@@ -90,7 +90,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div> <div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full"> <div class="w-full">
<List <List
class="w-full px-0" class="w-full px-3"
key={(p) => p.id} key={(p) => p.id}
items={providers.popular} items={providers.popular}
activeIcon="plus-small" activeIcon="plus-small"
@@ -37,7 +37,7 @@ const ModelList: Component<{
return ( return (
<List <List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`} class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }} search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")} emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`} key={(x) => `${x.provider.id}:${x.id}`}
@@ -29,6 +29,7 @@ export const DialogSelectProvider: Component = () => {
return ( return (
<Dialog title={language.t("command.provider.connect")} transition> <Dialog title={language.t("command.provider.connect")} transition>
<List <List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }} search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")} emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small" activeIcon="plus-small"
@@ -121,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
} }
return ( return (
<div class="px-5"> <div class="">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3"> <div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative"> <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField <TextField
@@ -172,6 +172,14 @@ function ServerForm(props: ServerFormProps) {
} }
export function DialogSelectServer() { export function DialogSelectServer() {
return <ServerManagement dialog />
}
export function ServerManagementSettings() {
return <ServerManagement />
}
function ServerManagement(props: { dialog?: boolean }) {
const navigate = useNavigate() const navigate = useNavigate()
const dialog = useDialog() const dialog = useDialog()
const server = useServer() const server = useServer()
@@ -351,7 +359,7 @@ export function DialogSelectServer() {
async function select(conn: ServerConnection.Any, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() if (props.dialog) dialog.close()
if (persist && conn.type === "http") { if (persist && conn.type === "http") {
server.add(conn) server.add(conn)
navigate("/") navigate("/")
@@ -502,148 +510,151 @@ export function DialogSelectServer() {
} }
} }
return ( const content = () => (
<Dialog title={formTitle()}> <div class="flex flex-1 min-h-0 flex-col gap-4">
<div class="flex flex-1 min-h-0 flex-col gap-2"> <Show when={!props.dialog && isFormMode()}>
<div class="text-16-medium text-text-strong">{formTitle()}</div>
</Show>
<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
class="px-3 flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-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"
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<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={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
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() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<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"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show>
<div class="shrink-0 pb-5">
<Show <Show
when={!isFormMode()} when={isFormMode()}
fallback={ fallback={
<ServerForm <Button
value={isAddMode() ? store.addServer.url : store.editServer.value} variant="secondary"
name={isAddMode() ? store.addServer.name : store.editServer.name} icon="plus-small"
username={isAddMode() ? store.addServer.username : store.editServer.username} size="large"
password={isAddMode() ? store.addServer.password : store.editServer.password} onClick={startAdd}
placeholder={language.t("dialog.server.add.placeholder")} class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
busy={formBusy()} >
error={isAddMode() ? store.addServer.error : store.editServer.error} {language.t("dialog.server.add.button")}
status={isAddMode() ? store.addServer.status : store.editServer.status} </Button>
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
} }
> >
<List <Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
search={{ {formBusy()
placeholder: language.t("dialog.server.search.placeholder"), ? language.t("dialog.server.add.checking")
autofocus: false, : isAddMode()
}} ? language.t("dialog.server.add.button")
noInitialSelection : language.t("common.save")}
emptyMessage={language.t("dialog.server.empty")} </Button>
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-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 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={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
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() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<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"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show> </Show>
<div class="shrink-0 px-5 pb-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>
}
>
<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> </div>
</Dialog> </div>
) )
return props.dialog ? <Dialog title={formTitle()}>{content()}</Dialog> : content()
} }
@@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds" import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers" import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models" import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => { export const DialogSettings: Component = () => {
const language = useLanguage() const language = useLanguage()
@@ -17,7 +18,7 @@ export const DialogSettings: Component = () => {
<Dialog size="x-large" transition> <Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog"> <Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List> <Tabs.List>
<div class="flex flex-col justify-between h-full w-full"> <div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col gap-3 w-full pt-3"> <div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -31,6 +32,10 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" /> <Icon name="keyboard" />
{language.t("settings.tab.shortcuts")} {language.t("settings.tab.shortcuts")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div> </div>
</div> </div>
@@ -61,6 +66,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar"> <Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds /> <SettingsKeybinds />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar"> <Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders /> <SettingsProviders />
</Tabs.Content> </Tabs.Content>
@@ -0,0 +1,21 @@
import { type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerManagementSettings } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<ServerManagementSettings />
</div>
</div>
)
}
+1 -1
View File
@@ -21,7 +21,7 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
overflow: hidden; overflow: hidden;
padding: 0 12px; /*padding: 0 12px;*/
[data-slot="list-search-wrapper"] { [data-slot="list-search-wrapper"] {
display: flex; display: flex;