From 6e87e858e868026838ad9fdda43e6ee2578256e2 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:06:17 +0200 Subject: [PATCH 01/14] refactor(app): reorganize desktop settings --- packages/app/src/settings/general/general.tsx | 33 +++++++------------ packages/app/src/settings/shell.tsx | 4 +-- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index fbf0d9d01e0c..478ebdc6a2b3 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -332,6 +332,17 @@ export const SettingsGeneral: Component<{ + + +
+ +
+
+
+ ) - // We can probably remove this, right? - const DisplaySection = () => ( - -
-

{language.t("settings.general.section.display")}

- - - -
- -
-
-
-
-
- ) - return ( <>
@@ -533,8 +524,6 @@ export const SettingsGeneral: Component<{ - -
diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 4c7439851964..688d93404109 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -34,14 +34,12 @@ const sections = [ { value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" }, ], [ - { value: "servers", icon: "server", label: "status.popover.tab.servers" }, { value: "projects", icon: "folder", label: "settings.tab.projects" }, { value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" }, - ], - [ { value: "providers", icon: "providers", label: "settings.providers.title" }, { value: "models", icon: "models", label: "settings.models.title" }, { value: "extensions", icon: "extensions", label: "settings.tab.extensions" }, + { value: "servers", icon: "server", label: "status.popover.tab.servers" }, ], [{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }], [{ value: "about", icon: "info", label: "settings.tab.about" }], From 23b87714e3bd2c9e93358cca05b47eb51f81e44b Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:06:43 +0200 Subject: [PATCH 02/14] refactor(app): unify settings layout spacing --- packages/app/src/providers/models/manage.tsx | 2 +- .../src/providers/models/select-dialog.tsx | 2 +- packages/app/src/settings/general/general.tsx | 2 +- .../app/src/settings/keybinds/keybinds.tsx | 2 +- packages/app/src/settings/models/models.tsx | 2 +- .../settings/notifications/notifications.tsx | 2 +- .../app/src/settings/providers/extensions.tsx | 2 +- .../app/src/settings/providers/providers.tsx | 2 +- packages/app/src/settings/settings.css | 127 ++++++------------ .../app/src/settings/workspaces/projects.tsx | 2 +- .../src/settings/workspaces/workspaces.tsx | 2 +- 11 files changed, 52 insertions(+), 95 deletions(-) diff --git a/packages/app/src/providers/models/manage.tsx b/packages/app/src/providers/models/manage.tsx index 808621f6f282..4bf1b36dee11 100644 --- a/packages/app/src/providers/models/manage.tsx +++ b/packages/app/src/providers/models/manage.tsx @@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => { - {group.items[0].provider.name} + {group.items[0].provider.name} - {group.items[0].provider.name} + {group.items[0].provider.name} diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 478ebdc6a2b3..304957f0b68e 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -505,7 +505,7 @@ export const SettingsGeneral: Component<{ -
+
diff --git a/packages/app/src/settings/keybinds/keybinds.tsx b/packages/app/src/settings/keybinds/keybinds.tsx index a6d03d5f1441..cc76c564dcc3 100644 --- a/packages/app/src/settings/keybinds/keybinds.tsx +++ b/packages/app/src/settings/keybinds/keybinds.tsx @@ -417,7 +417,7 @@ function SettingsKeybindsView(props: {
-
+
{(group) => ( 0}> diff --git a/packages/app/src/settings/models/models.tsx b/packages/app/src/settings/models/models.tsx index d51ccf1b262c..847d4b93d04d 100644 --- a/packages/app/src/settings/models/models.tsx +++ b/packages/app/src/settings/models/models.tsx @@ -160,7 +160,7 @@ export const SettingsModels: Component = () => { height={PROVIDER_ICON_SIZE} class="settings-models-provider-icon shrink-0" /> - {group.items[0].provider.name} + {group.items[0].provider.name} diff --git a/packages/app/src/settings/notifications/notifications.tsx b/packages/app/src/settings/notifications/notifications.tsx index 9b891b16c187..d8e72c54d235 100644 --- a/packages/app/src/settings/notifications/notifications.tsx +++ b/packages/app/src/settings/notifications/notifications.tsx @@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
-
+

{language.t("settings.general.section.notifications")}

diff --git a/packages/app/src/settings/providers/extensions.tsx b/packages/app/src/settings/providers/extensions.tsx index b34bebd94458..6a7489755b0b 100644 --- a/packages/app/src/settings/providers/extensions.tsx +++ b/packages/app/src/settings/providers/extensions.tsx @@ -68,7 +68,7 @@ export const SettingsExtensions: Component = () => {
- + {language.t("settings.extensions.tab.mcps")} {language.t("status.popover.tab.plugins")} diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index a9d841f53a25..4c32fcbc4c03 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -138,7 +138,7 @@ export const SettingsProviders: Component<{
-
+

{language.t("settings.providers.section.connected")}

diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 2ab7007b3298..efd81651ffa2 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -54,8 +54,7 @@ } .settings-screen .settings-tab-header { - padding: 48px 0 32px; - background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); + padding: 48px 0 0; } .settings-screen .settings-tab-body { @@ -249,8 +248,18 @@ position: sticky; top: 0; z-index: 10; - padding: 40px 40px 32px; - background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); + padding: 40px 40px 0; + background: var(--v2-background-bg-base); +} + +.settings-tab-header::before { + content: ""; + position: absolute; + inset-inline: 0; + top: 100%; + height: 24px; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); + pointer-events: none; } .settings-tab-header-row { @@ -271,9 +280,13 @@ .settings-tab-body { display: flex; flex-direction: column; - gap: 36px; + gap: 32px; width: 100%; - padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px)); + padding: 24px 40px calc(80px + var(--settings-bottom-inset, 0px)); +} + +.settings-tab-body--sectioned { + padding-top: 32px; } [data-slot="settings-row-description"] a.settings-link { @@ -293,18 +306,25 @@ } .settings-section-title { - padding-bottom: 8px; - font-size: 15px; - font-weight: 640; - line-height: 1; + padding: 0; + font-size: 13px; + font-weight: 530; + line-height: var(--line-height-compact); color: var(--v2-text-text-base); } .settings-section-title + [data-component="settings-list"] { - margin-top: -4px; + margin-top: 0; margin-bottom: 0; } +.settings-section-stack { + display: flex; + width: 100%; + flex-direction: column; + gap: 32px; +} + [data-component="settings-list"] { border-radius: 8px; background-color: var(--v2-background-bg-layer-01); @@ -465,8 +485,7 @@ padding-top: 12px; } - .settings-screen .settings-tab-title, - .settings-screen .settings-section-title { + .settings-screen .settings-tab-title { line-height: var(--line-height-base); } @@ -612,30 +631,10 @@ color: var(--v2-text-text-accent-hover); } -.settings-tab-body.settings-providers { - gap: 32px; -} - -.settings-tab-header:has(+ .settings-tab-body.settings-providers) { - padding-bottom: 32px; -} - -.settings-providers .settings-section-title { - padding-bottom: 0; - font-size: 13px; - font-weight: 530; - line-height: var(--line-height-compact); -} - -.settings-providers .settings-section-title + [data-component="settings-list"] { - margin-top: 16px; -} - .settings-tab-header.settings-tab-header--stacked { display: flex; flex-direction: column; - gap: 32px; - padding-bottom: 32px; + gap: 24px; } .settings-tab-header--stacked > .settings-tab-header-row { @@ -728,11 +727,10 @@ gap: 8px; } -.settings-models .settings-section-title { - padding-bottom: 0; +.settings-models-group-title { font-size: 13px; font-weight: 530; - line-height: 16px; + line-height: var(--line-height-compact); } .settings-models [data-component="provider-icon"] { @@ -743,10 +741,6 @@ padding-inline: 16px; } -.settings-models .settings-section-title + [data-component="settings-list"] { - margin-top: 0; -} - .settings-models [data-slot="settings-row-description"]:empty { display: none; } @@ -792,17 +786,6 @@ color: var(--v2-text-text-base); } -.settings-shortcuts .settings-section { - gap: 16px; -} - -.settings-shortcuts .settings-section-title { - padding-bottom: 0; - font-size: 13px; - font-weight: 530; - line-height: var(--line-height-compact); -} - .settings-shortcuts [data-component="settings-list"] { display: flex; flex-direction: column; @@ -895,22 +878,6 @@ gap: 0; } -.settings-tab-header.settings-servers-header { - padding-bottom: 24px; -} - -.settings-servers-header .settings-tab-header-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.settings-tab-header.settings-servers-header.settings-tab-header--stacked { - gap: 24px; - padding-bottom: 24px; -} - .settings-servers [data-component="settings-list"] { display: flex; flex-direction: column; @@ -988,16 +955,8 @@ color: var(--v2-text-text-base); } -.settings-tab-header.settings-workspaces-header { - padding-bottom: 24px; -} - -.settings-workspaces-header .settings-tab-title { - font-weight: 610; -} - .settings-tab-body.settings-workspaces { - gap: 16px; + gap: 0; } .settings-workspaces-toolbar { @@ -1006,13 +965,7 @@ align-items: center; justify-content: space-between; gap: 16px; -} - -.settings-workspaces-count { - font-size: 15px; - font-weight: 530; - line-height: 1; - color: var(--v2-text-text-base); + padding-bottom: 8px; } .settings-workspaces-toolbar-actions { @@ -1255,7 +1208,7 @@ @media (max-width: 639px) { .settings-workspaces-header { - padding: 24px 20px 20px; + padding: 24px 20px 0; } .settings-tab-body.settings-workspaces { @@ -1374,6 +1327,10 @@ padding-inline: 0 !important; } +.settings-subtabs > [data-slot="tabs-v2-content"] { + padding-top: 24px; +} + .settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { display: none; } diff --git a/packages/app/src/settings/workspaces/projects.tsx b/packages/app/src/settings/workspaces/projects.tsx index 17bfa636855f..4eb68c3e83d3 100644 --- a/packages/app/src/settings/workspaces/projects.tsx +++ b/packages/app/src/settings/workspaces/projects.tsx @@ -115,7 +115,7 @@ export const SettingsProjects: Component = () => {
} > -
+
0} fallback={ diff --git a/packages/app/src/settings/workspaces/workspaces.tsx b/packages/app/src/settings/workspaces/workspaces.tsx index eaf9a3b6c192..f7fdc7c3e619 100644 --- a/packages/app/src/settings/workspaces/workspaces.tsx +++ b/packages/app/src/settings/workspaces/workspaces.tsx @@ -335,7 +335,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
0}>
- + {language.plural("settings.workspaces.count", filtered().length)}
From 223e9a8c417b5ecf8f01d9af4a46e24f513e856a Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:07:04 +0200 Subject: [PATCH 03/14] refactor(ui): refresh settings navigation icons --- .../ui/src/icons/icon/additional-icons.ts | 24 +++++++++++++------ packages/ui/src/icons/icon/icon.tsx | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/icons/icon/additional-icons.ts b/packages/ui/src/icons/icon/additional-icons.ts index bdeafd22c5f4..1391ac644202 100644 --- a/packages/ui/src/icons/icon/additional-icons.ts +++ b/packages/ui/src/icons/icon/additional-icons.ts @@ -93,23 +93,33 @@ export const additionalIcons = { dash: ``, "cloud-upload": ``, trash: ``, - sliders: ``, + sliders: ``, keyboard: ``, selector: ``, "arrow-down-to-line": ``, warning: ``, reset: ``, link: ``, - providers: ``, - models: ``, - appearance: ``, - notifications: ``, - extensions: ``, + providers: ``, + models: ``, + appearance: ``, + notifications: ``, + extensions: ``, cube: ``, "post-skill": ``, "arrow-undo-down": ``, } export function additionalIconViewBox(name: keyof typeof additionalIcons) { - return name === "magnifying-glass" || name === "arrow-undo-down" || name === "subagent" ? "0 0 16 16" : "0 0 20 20" + return name === "magnifying-glass" || + name === "arrow-undo-down" || + name === "subagent" || + name === "notifications" || + name === "appearance" || + name === "extensions" || + name === "sliders" || + name === "providers" || + name === "models" + ? "0 0 16 16" + : "0 0 20 20" } diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 50239eb80c78..19123a521f49 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -18,7 +18,7 @@ const icons = { }, folder: { viewBox: "0 0 16 16", - body: ``, + body: ``, }, branch: { viewBox: "0 0 16 16", From cef086e5dba699357f7b141c2ed00d46e2b68067 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:59:17 +0200 Subject: [PATCH 04/14] feat(server): expose global preferences and shell discovery --- packages/client/src/effect/api/api.ts | 22 +++++++ .../client/src/effect/generated/client.ts | 24 ++++++- .../client/src/promise/generated/client.ts | 32 ++++++++++ .../client/src/promise/generated/types.ts | 21 +++++++ packages/core/src/config.ts | 62 ++++++++++++++++++- packages/core/src/shell/select.ts | 2 + packages/protocol/src/groups/config.ts | 35 +++++++++++ packages/schema/src/config.ts | 12 ++++ packages/schema/src/config/shell.ts | 10 +++ packages/server/src/handlers/config.ts | 29 ++++++++- packages/server/test/config.test.ts | 48 ++++++++++++++ 11 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 packages/schema/src/config/shell.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 38758b0a7997..1b1edda004aa 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -2111,8 +2111,30 @@ export type ConfigGetInput = { export type ConfigGetOutput = ReadonlyArray export type ConfigGetOperation = (input?: ConfigGetInput) => Effect.Effect +export type ConfigPreferencesOutput = Config.Preferences +export type ConfigPreferencesOperation = () => Effect.Effect + +export type ConfigUpdatePreferencesInput = { + readonly shell?: string | null | undefined + readonly websearch?: false | { readonly provider: "random" | WebSearch.ID } | null | undefined +} +export type ConfigUpdatePreferencesOutput = Config.Preferences +export type ConfigUpdatePreferencesOperation = ( + input?: ConfigUpdatePreferencesInput, +) => Effect.Effect + +export type ConfigShellsOutput = ReadonlyArray<{ + readonly path: string + readonly name: string + readonly acceptable: boolean +}> +export type ConfigShellsOperation = () => Effect.Effect + export interface ConfigApi { readonly get: ConfigGetOperation + readonly preferences: ConfigPreferencesOperation + readonly updatePreferences: ConfigUpdatePreferencesOperation + readonly shells: ConfigShellsOperation } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index e6cd7d6662d7..f9b46b9fe36d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -270,6 +270,10 @@ import type { WebsearchQueryOutput, ConfigGetInput, ConfigGetOutput, + ConfigPreferencesOutput, + ConfigUpdatePreferencesInput, + ConfigUpdatePreferencesOutput, + ConfigShellsOutput, } from "../api/api.js" import { ClientError } from "./client-error.js" @@ -1585,7 +1589,25 @@ const EndpointConfigGet = (raw: RawClient["server.config"]) => (input?: ConfigGe raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) -const adaptGroupConfig = (raw: RawClient["server.config"]) => ({ get: EndpointConfigGet(raw) }) +const EndpointConfigPreferences = (raw: RawClient["server.config"]) => () => + preserveEffect()(raw["config.preferences"]({}).pipe(Effect.mapError(mapClientError))) + +const EndpointConfigUpdatePreferences = (raw: RawClient["server.config"]) => (input?: ConfigUpdatePreferencesInput) => + preserveEffect()( + raw["config.updatePreferences"]({ payload: { shell: input?.["shell"], websearch: input?.["websearch"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + +const EndpointConfigShells = (raw: RawClient["server.config"]) => () => + preserveEffect()(raw["config.shells"]({}).pipe(Effect.mapError(mapClientError))) + +const adaptGroupConfig = (raw: RawClient["server.config"]) => ({ + get: EndpointConfigGet(raw), + preferences: EndpointConfigPreferences(raw), + updatePreferences: EndpointConfigUpdatePreferences(raw), + shells: EndpointConfigShells(raw), +}) const adaptClient = (raw: RawClient) => ({ health: adaptGroupHealth(raw["server.health"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 03ea39536d7b..150e446a1641 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -266,6 +266,10 @@ import type { WebsearchQueryOutput, ConfigGetInput, ConfigGetOutput, + ConfigPreferencesOutput, + ConfigUpdatePreferencesInput, + ConfigUpdatePreferencesOutput, + ConfigShellsOutput, } from "./types.js" import { ClientError } from "./client-error.js" @@ -2202,6 +2206,34 @@ export function make(options: ClientOptions) { }, requestOptions, ), + preferences: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/config/preferences`, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + updatePreferences: (input?: ConfigUpdatePreferencesInput, requestOptions?: RequestOptions) => + request( + { + method: "PATCH", + path: `/api/config/preferences`, + body: { shell: input?.["shell"], websearch: input?.["websearch"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + shells: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false }, + requestOptions, + ), }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7f8c229e9ff8..794e23a79e67 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -431,6 +431,10 @@ export type WebSearchResult = { url: string; title?: string; content?: string; t export type ConfigWorktree = { directory: string } +export type ConfigPreferences = { shell?: string; websearch?: false | { provider: "random" | (string & {}) } } + +export type ConfigShellOption = { path: string; name: string; acceptable: boolean } + export type ProviderRequest = { settings: ProviderSettings headers: { [x: string]: string } @@ -6358,3 +6362,20 @@ export type ConfigGetInput = { } export type ConfigGetOutput = Array + +export type ConfigPreferencesOutput = ConfigPreferences + +export type ConfigUpdatePreferencesInput = { + readonly shell?: { + readonly shell?: string | null + readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null + }["shell"] + readonly websearch?: { + readonly shell?: string | null + readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null + }["websearch"] +} + +export type ConfigUpdatePreferencesOutput = ConfigPreferences + +export type ConfigShellsOutput = Array diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6d6a92d12eaf..5ad679a99b7d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -3,7 +3,7 @@ export * as Config from "./config.js" import { makeLocationNode } from "@opencode/util/effect/app-node" import path from "path" import { isDeepStrictEqual } from "node:util" -import { type ParseError, parse } from "jsonc-parser" +import { applyEdits, modify, type ParseError, parse } from "jsonc-parser" import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect" import { AgentsDirectory, @@ -11,6 +11,8 @@ import { Directory, Document, Info, + type Preferences, + type PreferencesPatch, type Entry, Event, } from "@opencode/schema/config" @@ -41,6 +43,10 @@ export interface Interface { * source files they parse and rebuild their own state. */ readonly changes: () => Stream.Stream + /** Returns preferences from the highest-precedence global config document. */ + readonly preferences?: () => Effect.Effect + /** Patches preferences in the highest-precedence global config document. */ + readonly updatePreferences?: (patch: PreferencesPatch) => Effect.Effect } export const Options = Schema.Struct({ @@ -80,6 +86,19 @@ export const testLayer = (initial: Entry[] = []) => }), ) +function decodePreferences(text: string): Preferences { + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) return {} + const normalized = ConfigNormalize.normalize(input) + if (normalized.type === "rejected") return {} + const info = Option.getOrUndefined(Schema.decodeUnknownOption(Info)(normalized.encoded)) + return { + ...(info?.shell === undefined ? {} : { shell: info.shell }), + ...(info?.websearch === undefined ? {} : { websearch: info.websearch }), + } +} + export const layer = (options?: Options) => Layer.effect( Service, @@ -89,8 +108,10 @@ export const layer = (options?: Options) => const watcher = yield* Watcher.Service const bus = yield* Bus.Service const credentials = yield* Credential.Service + const globalService = yield* Global.Service const wellknown = yield* WellKnown.Service const reloadLock = Semaphore.makeUnsafe(1) + const updateLock = Semaphore.makeUnsafe(1) const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) { @@ -317,11 +338,50 @@ export const layer = (options?: Options) => ) yield* reloadLock.withPermit(reconcile(initial)) + const globalConfigPath = Effect.fn("Config.globalConfigPath")(function* () { + const directory = initial.global ?? AbsolutePath.make(globalService.config) + const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name)) + const existing = yield* Effect.filter(candidates, fs.isFile) + return existing.at(-1) ?? path.join(directory, "opencode.jsonc") + }) + + const preferences = Effect.fn("Config.preferences")(function* () { + const filepath = yield* globalConfigPath() + const text = yield* fs.readFileStringSafe(filepath) + return text === undefined ? {} : decodePreferences(text) + }) + + const updatePreferences = Effect.fn("Config.updatePreferences")( + function* (patch: PreferencesPatch) { + const filepath = yield* globalConfigPath() + const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n" + const updated = yield* Effect.try({ + try: () => + (["shell", "websearch"] as const).reduce((content, key) => { + if (!Object.prototype.hasOwnProperty.call(patch, key)) return content + return applyEdits( + content, + modify(content, [key], patch[key] === null ? undefined : patch[key], { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }), + ) + }, text), + catch: (cause) => new FSUtil.FileSystemError({ method: "config.updatePreferences", cause }), + }) + yield* fs.writeWithDirs(filepath, updated.endsWith("\n") ? updated : `${updated}\n`) + yield* requestReload + return decodePreferences(updated) + }, + (effect) => updateLock.withPermit(effect), + ) + return Service.of({ entries: Effect.fnUntraced(function* () { return configs }), changes: () => Stream.fromPubSub(updates), + preferences, + updatePreferences, }) }), ) diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 5d0cfb022a16..6645454c0a45 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -47,6 +47,7 @@ export type ResolveInput = { export interface Interface extends State.Transformable { readonly resolve: (input: ResolveInput) => Effect.Effect + readonly list?: () => Effect.Effect } export class Service extends Context.Service()("@opencode/ShellSelect") {} @@ -214,6 +215,7 @@ const layer = (options?: Options) => transform: state.transform, reload: state.reload, resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)), + list: () => Effect.promise(() => list(options, global.bin)), }) }), ) diff --git a/packages/protocol/src/groups/config.ts b/packages/protocol/src/groups/config.ts index 15b65298dafb..3ecf4fa0274f 100644 --- a/packages/protocol/src/groups/config.ts +++ b/packages/protocol/src/groups/config.ts @@ -1,4 +1,5 @@ import { Config } from "@opencode/schema/config" +import { ConfigShell } from "@opencode/schema/config/shell" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { LocationQuery, locationQueryOpenApi } from "./location.js" @@ -19,4 +20,38 @@ export const ConfigGroup = HttpApiGroup.make("server.config") }), ), ) + .add( + HttpApiEndpoint.get("config.preferences", "/api/config/preferences", { + success: Config.Preferences, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.config.preferences", + summary: "Get global preferences", + description: "Return preferences from the highest-precedence global configuration document.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("config.updatePreferences", "/api/config/preferences", { + payload: Config.PreferencesPatch, + success: Config.Preferences, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.config.updatePreferences", + summary: "Update global preferences", + description: "Patch preferences in the highest-precedence global configuration document.", + }), + ), + ) + .add( + HttpApiEndpoint.get("config.shells", "/api/config/shell", { + success: Schema.Array(ConfigShell.Option), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.config.shells", + summary: "List available shells", + description: "Return shells available to terminal and agent execution.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "config", description: "Location-scoped configuration routes." })) diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index e23c2bce6bbd..11474fbe5086 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -109,6 +109,18 @@ export class Info extends Schema.Class("Config.Info")({ experimental: ConfigExperimental.Info.pipe(optional), }) {} +export const Preferences = Schema.Struct({ + shell: Schema.String.pipe(optional), + websearch: ConfigWebSearch.Selection.pipe(optional), +}).annotate({ identifier: "Config.Preferences" }) +export interface Preferences extends Schema.Schema.Type {} + +export const PreferencesPatch = Schema.Struct({ + shell: Schema.NullOr(Schema.String).pipe(optional), + websearch: Schema.NullOr(ConfigWebSearch.Selection).pipe(optional), +}).annotate({ identifier: "Config.PreferencesPatch" }) +export interface PreferencesPatch extends Schema.Schema.Type {} + export class Document extends Schema.Class("Config.Document")({ type: Schema.Literal("document"), path: AbsolutePath.pipe(optional), diff --git a/packages/schema/src/config/shell.ts b/packages/schema/src/config/shell.ts new file mode 100644 index 000000000000..52278b27eae3 --- /dev/null +++ b/packages/schema/src/config/shell.ts @@ -0,0 +1,10 @@ +export * as ConfigShell from "./shell.js" + +import { Schema } from "effect" + +export const Option = Schema.Struct({ + path: Schema.String, + name: Schema.String, + acceptable: Schema.Boolean, +}).annotate({ identifier: "ConfigShell.Option" }) +export interface Option extends Schema.Schema.Type {} diff --git a/packages/server/src/handlers/config.ts b/packages/server/src/handlers/config.ts index 176a3b71075a..845071bd658b 100644 --- a/packages/server/src/handlers/config.ts +++ b/packages/server/src/handlers/config.ts @@ -1,7 +1,34 @@ import { Config } from "@opencode/core/config" +import { ShellSelect } from "@opencode/core/shell/select" +import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" export const ConfigHandler = HttpApiBuilder.group(Api, "server.config", (handlers) => - handlers.handle("config.get", () => Config.Service.use((config) => config.entries())), + handlers + .handle("config.get", () => Config.Service.use((config) => config.entries())) + .handle( + "config.preferences", + Effect.fn(function* () { + const config = yield* Config.Service + if (!config.preferences) return yield* Effect.die(new Error("Config preferences are unavailable")) + return yield* config.preferences().pipe(Effect.orDie) + }), + ) + .handle( + "config.updatePreferences", + Effect.fn(function* (ctx) { + const config = yield* Config.Service + if (!config.updatePreferences) return yield* Effect.die(new Error("Config preference updates are unavailable")) + return yield* config.updatePreferences(ctx.payload).pipe(Effect.orDie) + }), + ) + .handle( + "config.shells", + Effect.fn(function* () { + const shell = yield* ShellSelect.Service + if (!shell.list) return yield* Effect.die(new Error("Shell discovery is unavailable")) + return yield* shell.list() + }), + ), ) diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts index fc4301e07820..fbc76010d62c 100644 --- a/packages/server/test/config.test.ts +++ b/packages/server/test/config.test.ts @@ -59,6 +59,54 @@ it.live("returns ordered config entries for the requested directory", () => }), ) +it.live("updates global preferences without replacing unrelated JSONC", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-preferences-"))) + const global = path.join(tmp.path, "global") + const config = path.join(global, "opencode.jsonc") + yield* Effect.promise(() => fs.mkdir(global, { recursive: true })) + yield* Effect.promise(() => + fs.writeFile( + config, + `{ + // keep this comment + "model": "provider/model", + "shell": "bash" +} +`, + ), + ) + const server = yield* startServer(global) + const response = yield* Effect.promise(() => + fetch(new URL("/api/config/preferences", server.base), { + method: "PATCH", + headers: { ...server.headers, "content-type": "application/json" }, + body: JSON.stringify({ shell: null, websearch: { provider: "random" } }), + }), + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toEqual({ websearch: { provider: "random" } }) + const text = yield* Effect.promise(() => fs.readFile(config, "utf8")) + expect(text).toContain("// keep this comment") + expect(text).toContain('"model": "provider/model"') + expect(text).not.toContain('"shell"') + expect(text).toContain('"websearch"') + + const preferences = yield* Effect.promise(() => + fetch(new URL("/api/config/preferences", server.base), { headers: server.headers }), + ) + expect(preferences.status).toBe(200) + expect(yield* Effect.promise(() => preferences.json())).toEqual({ websearch: { provider: "random" } }) + + const shells = yield* Effect.promise(() => + fetch(new URL("/api/config/shell", server.base), { headers: server.headers }), + ) + expect(shells.status).toBe(200) + expect(Array.isArray(yield* Effect.promise(() => shells.json()))).toBe(true) + }), +) + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } From 2e57ac20ff4ecc4f890f9d6624cbc01efada0c9e Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:00:48 +0200 Subject: [PATCH 05/14] feat(app): add nested server and project settings Scope settings by server, replace project dialogs with autosaving views, preserve WSL management, and refine extension lists and controls. --- .../remote-session-settings.spec.ts | 23 +- .../regression/server-dialog-focus.spec.ts | 8 +- .../regression/session-project-menu.spec.ts | 28 +- .../e2e/regression/settings-loading.spec.ts | 133 ++++ .../e2e/regression/settings-padding.spec.ts | 2 +- .../app/e2e/regression/settings-wsl.spec.ts | 48 ++ packages/app/e2e/utils/mock-api.ts | 16 + packages/app/e2e/utils/mock-server.ts | 24 +- .../app/e2e/utils/settings-wsl.fixture.tsx | 135 ++++ packages/app/e2e/utils/settings-wsl.html | 14 + packages/app/playwright.config.ts | 1 + packages/app/src/home/projects/controller.tsx | 8 +- packages/app/src/new-session/screen.tsx | 14 +- packages/app/src/runtime/i18n/en.ts | 20 + packages/app/src/runtime/server/runtime.tsx | 28 - packages/app/src/servers/connect/dialog.tsx | 11 +- .../src/servers/registry/controller.test.ts | 24 + .../app/src/servers/registry/controller.ts | 52 +- .../app/src/servers/registry/row-menu.tsx | 4 +- packages/app/src/servers/wsl/settings.tsx | 47 +- .../src/session/session-identity-header.tsx | 17 +- packages/app/src/settings/command.tsx | 3 +- .../app/src/settings/general/controllers.ts | 107 +++- packages/app/src/settings/general/general.tsx | 10 +- packages/app/src/settings/models/models.tsx | 2 - packages/app/src/settings/navigation.tsx | 110 ++++ .../app/src/settings/providers/extensions.tsx | 30 +- .../app/src/settings/providers/providers.tsx | 9 +- packages/app/src/settings/server-scope.tsx | 16 +- packages/app/src/settings/server-select.tsx | 48 -- .../app/src/settings/servers/inventory.ts | 43 ++ packages/app/src/settings/servers/servers.tsx | 198 +++--- packages/app/src/settings/settings.css | 117 +++- packages/app/src/settings/shell.tsx | 576 ++++++++++++------ packages/app/src/settings/surface.tsx | 140 ++++- .../settings/workspaces/project-dialog.css | 238 -------- .../settings/workspaces/project-dialog.tsx | 219 ------- .../workspaces/project-extensions.tsx | 161 +++-- .../src/settings/workspaces/project-model.ts | 171 +++--- .../app/src/settings/workspaces/project.css | 326 ++++++++++ .../app/src/settings/workspaces/project.tsx | 158 +++++ .../app/src/settings/workspaces/projects.tsx | 148 +---- .../src/settings/workspaces/workspaces.tsx | 24 +- packages/app/vite.config.ts | 3 + 44 files changed, 2267 insertions(+), 1247 deletions(-) create mode 100644 packages/app/e2e/regression/settings-wsl.spec.ts create mode 100644 packages/app/e2e/utils/settings-wsl.fixture.tsx create mode 100644 packages/app/e2e/utils/settings-wsl.html create mode 100644 packages/app/src/servers/registry/controller.test.ts create mode 100644 packages/app/src/settings/navigation.tsx delete mode 100644 packages/app/src/settings/server-select.tsx create mode 100644 packages/app/src/settings/servers/inventory.ts delete mode 100644 packages/app/src/settings/workspaces/project-dialog.css delete mode 100644 packages/app/src/settings/workspaces/project-dialog.tsx create mode 100644 packages/app/src/settings/workspaces/project.css create mode 100644 packages/app/src/settings/workspaces/project.tsx diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 5fa2b4b24a9e..1f11c4471dfe 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => { // one toggle sweeps every connected server, not just the focused one. await mockServers(page, permissionRequests, permissionResponses, { pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] }, + preferencesUnavailable: true, }) await configureServers(page) @@ -36,6 +37,8 @@ test("session settings use the remote server context", async ({ page }) => { await expect(page.getByRole("dialog")).toHaveCount(0) await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px") await expect(sessionHeading).toBeHidden() + await expect(settings.getByText("Servers", { exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0) const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]') const input = autoAccept.getByRole("switch") await expect(autoAccept).toBeVisible() @@ -63,9 +66,21 @@ test("session settings use the remote server context", async ({ page }) => { }, ]) + await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click() + await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible() + await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible() + await expect(settings.getByRole("tab")).toHaveText([ + "127.0.0.1:4097", + "Projects", + "Worktrees", + "Providers", + "Models", + "Extensions", + ]) await settings.getByRole("tab", { name: "Models" }).click() await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled() await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) + await settings.getByRole("button", { name: "Back to settings" }).click() await settings.getByRole("button", { name: "Back to app" }).click() await expect(settings).toBeHidden() await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) @@ -73,7 +88,7 @@ test("session settings use the remote server context", async ({ page }) => { await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title) await page.keyboard.press("Control+]") await expect(page).toHaveURL("/settings") - await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true") await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0) await page.keyboard.press("Escape") await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) @@ -311,6 +326,7 @@ type MockServerOptions = { listFailures?: Record // Records /api/session/:id GETs so tests can assert session resyncs. sessionGets?: string[] + preferencesUnavailable?: boolean } async function mockServers( @@ -401,6 +417,11 @@ async function mockServers( directory, project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory }, }) + if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200) + if (url.pathname === "/api/config/shell") + return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200) + if (url.pathname === "/api/websearch/provider") + return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200) if (url.pathname === "/api/worktree") return json(route, [{ directory }]) if (url.pathname === "/api/vcs") return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) diff --git a/packages/app/e2e/regression/server-dialog-focus.spec.ts b/packages/app/e2e/regression/server-dialog-focus.spec.ts index d68c19e20396..ba9a1716a651 100644 --- a/packages/app/e2e/regression/server-dialog-focus.spec.ts +++ b/packages/app/e2e/regression/server-dialog-focus.spec.ts @@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) => const settings = page.getByTestId("settings-screen") await expect(settings).toBeVisible() await expect(page.getByRole("dialog")).toHaveCount(0) - await settings.getByRole("tab", { name: "Servers" }).click() - await settings.getByRole("button", { name: "Add server" }).click() + const add = settings.getByRole("button", { name: "Add server" }) + const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }) + await expect(add).toHaveCSS("opacity", "0") + await group.hover() + await expect(add).toHaveCSS("opacity", "1") + await add.click() const editor = page.getByRole("dialog", { name: "Add server" }) await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused() diff --git a/packages/app/e2e/regression/session-project-menu.spec.ts b/packages/app/e2e/regression/session-project-menu.spec.ts index 3c32189ab56a..c79761db1ec1 100644 --- a/packages/app/e2e/regression/session-project-menu.spec.ts +++ b/packages/app/e2e/regression/session-project-menu.spec.ts @@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(settings).toBeFocused() await expect(page.getByRole("tooltip")).toBeHidden() await page.keyboard.press("Enter") - const dialog = page.getByRole("dialog") - await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible() - await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue( - project.name, - ) + const settingsScreen = page.getByTestId("settings-screen") + await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible() + await expect( + settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }), + ).toHaveValue(project.name) await expect(menu).toBeHidden() - await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click() - await expect(dialog).toBeHidden() + await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click() + await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click() + await expect(settingsScreen).toBeHidden() await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) await page.setViewportSize({ width: 1440, height: 900 }) @@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) { await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled() await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled() await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click() - const dialog = page.getByRole("dialog") - await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue( - fixture.project.name, - ) - await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click() - await expect(dialog).toBeHidden() + const settingsScreen = page.getByTestId("settings-screen") + await expect( + settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }), + ).toHaveValue(fixture.project.name) + await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click() + await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click() + await expect(settingsScreen).toBeHidden() } await trigger.click() await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click() diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index bb587554780e..0c43d5844041 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -13,11 +13,20 @@ test.beforeEach(async ({ page }) => { id: "proj_settings_demo", canonical: directory, name: "Settings demo", + icon: { + color: "orange", + override: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E", + }, + commands: { start: "echo setup" }, vcs: "git", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes, }, provider: { all: [], connected: [], default: {} }, + preferences: { shell: "zsh", websearch: { provider: "exa" } }, + shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }], + websearchProviders: [{ id: "exa", name: "Exa" }], sessions: sandboxes.map((directory, index) => ({ id: `ses_settings_${index + 1}`, title: `Workspace ${index + 1} session`, @@ -56,6 +65,130 @@ test("settings has its own route and returns through app history", async ({ page await expect(home).toHaveAttribute("aria-pressed", "true") }) +test("single-server settings expose scoped pages without a server picker", async ({ page }) => { + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0) + + for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) { + await settings.getByRole("tab", { name, exact: true }).click() + await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0) + } + + await settings.getByRole("tab", { name: "Server", exact: true }).click() + await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible() + await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible() + await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible() + await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible() + await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible() + await expect(settings.getByText("zsh", { exact: true })).toBeVisible() + await expect(settings.getByText("Exa", { exact: true })).toBeVisible() + + await settings.getByText("Exa", { exact: true }).click() + const updated = page.waitForRequest( + (request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences", + ) + await page.getByRole("option", { name: "Any", exact: true }).click() + expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } }) +}) + +test("server details tolerate unavailable preference endpoints", async ({ page }) => { + await page.route( + (url) => + url.pathname === "/api/config/preferences" || + url.pathname === "/api/config/shell" || + url.pathname === "/api/websearch/provider", + (route) => route.fulfill({ status: 404, json: {} }), + ) + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Server", exact: true }).click() + + const connection = settings.locator('[data-component="settings-server-connection"]') + await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible() + await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px") + await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px") + await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px") + await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px") + await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0) +}) + +test("project settings open as a nested autosaving view", async ({ page }) => { + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await settings.getByRole("button", { name: "Settings demo", exact: true }).click() + + await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0) + + const name = settings.getByRole("textbox", { name: "Project name", exact: true }) + const saved = page.waitForRequest( + (request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo", + ) + await name.fill("Renamed project") + await name.blur() + expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" }) + + const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true }) + const scriptSaved = page.waitForRequest( + (request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo", + ) + await startup.fill("bun install") + await startup.blur() + expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } }) + + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible() + await page.keyboard.press("Escape") + await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible() +}) + +test("clearing project fields sends explicit removal values", async ({ page }) => { + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await settings.getByRole("button", { name: "Settings demo", exact: true }).click() + const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true }) + await expect(startup).toHaveValue("echo setup") + + const scriptSaved = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo", + ) + await startup.clear() + await startup.blur() + const scriptResponse = await scriptSaved + expect(scriptResponse.ok()).toBe(true) + expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } }) + await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0) + + const icon = settings.getByRole("button", { name: "Project icon", exact: true }) + await expect(icon.locator("img")).toHaveCount(1) + const iconSaved = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo", + ) + await icon.hover() + await icon.click() + const iconResponse = await iconSaved + expect(iconResponse.ok()).toBe(true) + expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } }) + await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0) + + const color = settings.getByRole("button", { name: "Select orange color", exact: true }) + await expect(color).toHaveAttribute("aria-pressed", "true") + const colorSaved = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo", + ) + await color.click() + const colorResponse = await colorSaved + expect(colorResponse.ok()).toBe(true) + expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } }) + await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0) +}) + test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => { const settings = page.getByTestId("settings-screen") await expect(settings).toBeFocused() diff --git a/packages/app/e2e/regression/settings-padding.spec.ts b/packages/app/e2e/regression/settings-padding.spec.ts index 3c77c1a58177..ef9f4547ba68 100644 --- a/packages/app/e2e/regression/settings-padding.spec.ts +++ b/packages/app/e2e/regression/settings-padding.spec.ts @@ -50,12 +50,12 @@ for (const viewport of [ "Appearance", "Notifications", "Shortcuts", - "Servers", "Projects", "Worktrees", "Providers", "Models", "Extensions", + "Server", "Experimental", "About", ]) { diff --git a/packages/app/e2e/regression/settings-wsl.spec.ts b/packages/app/e2e/regression/settings-wsl.spec.ts new file mode 100644 index 000000000000..2448051313ef --- /dev/null +++ b/packages/app/e2e/regression/settings-wsl.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" + +test.use({ viewport: { width: 1280, height: 900 } }) + +for (const mode of ["failed", "stopped", "ready"] as const) { + test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => { + await mockOpenCodeServer(page, { + directory: "/repo", + project: { + id: "proj_wsl_settings", + canonical: "/repo", + name: "WSL project", + sandboxes: [], + time: { created: 1, updated: 1 }, + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`) + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled() + const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true }) + await expect(ubuntu).toHaveCount(1) + await ubuntu.click() + await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible() + const connection = settings.locator('[data-component="settings-server-connection"]') + + if (mode !== "ready") { + await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled() + await connection.getByRole("button", { name: "More options", exact: true }).click() + await page.getByRole("menuitem", { name: "Retry start", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu") + } + await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled() + await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu") + await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0) + + await connection.getByRole("button", { name: "More options", exact: true }).click() + await page.getByRole("menuitem", { name: "Remove", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu") + await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0) + await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled() + }) +} diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 0b0c7eeaa71b..e7edf9770ffb 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -69,7 +69,23 @@ const Group = HttpApiGroup.make("mock") .add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json })) .add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json })) .add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json })) + .add( + HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", { + params: { projectID: Schema.String }, + payload: JsonPayload, + success: Json, + }), + ) .add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json })) + .add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json })) + .add( + HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", { + payload: JsonPayload, + success: Json, + }), + ) + .add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json })) + .add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json })) .add( HttpApiEndpoint.get("worktreeList", "/api/worktree", { success: Json, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index ee30cb457eec..76550b098bcf 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -9,6 +9,10 @@ export interface MockServerConfig { provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onProjectUpdate?: (input: { projectID: string; body: unknown }) => void + preferences?: Record + shells?: unknown[] + websearchProviders?: unknown[] directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -179,13 +183,14 @@ export function createMockServerHandler(config: MockServerConfig) { const corsHeaders = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", - "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "access-control-expose-headers": "x-next-cursor", } function mockHandlers(config: MockServerConfig, state: { cursors: Map; nextCursor: number }) { const noContent = Effect.succeed(HttpApiSchema.NoContent.make()) const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay)) + const preferences = { current: config.preferences ?? {} } return HttpApiBuilder.group(MockApi, "mock", (handlers) => handlers .handleRaw("event", () => { @@ -262,12 +267,29 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map + Effect.sync(() => config.onProjectUpdate?.({ projectID: ctx.params.projectID, body: ctx.payload })).pipe( + Effect.as({ + ...(config.project as Record), + ...ctx.payload, + id: ctx.params.projectID, + canonical: (config.project as { canonical?: string; worktree?: string }).canonical ?? config.directory, + }), + ), projectCurrent: () => Effect.succeed({ id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory, }), + configPreferences: () => Effect.succeed(preferences.current), + configUpdatePreferences: (ctx) => + Effect.sync(() => { + preferences.current = { ...preferences.current, ...ctx.payload } + return preferences.current + }), + configShells: () => Effect.succeed(config.shells ?? []), + websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }), worktreeList: () => Effect.succeed([ { directory: config.directory }, diff --git a/packages/app/e2e/utils/settings-wsl.fixture.tsx b/packages/app/e2e/utils/settings-wsl.fixture.tsx new file mode 100644 index 000000000000..da33aa2c96cc --- /dev/null +++ b/packages/app/e2e/utils/settings-wsl.fixture.tsx @@ -0,0 +1,135 @@ +import { MemoryRouter, createMemoryHistory } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import { createStore, unwrap } from "solid-js/store" +import { render } from "solid-js/web" +import { AppBaseProviders, AppInterface } from "../../src/app" +import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform" +import { ServerConnection } from "../../src/runtime/server/registry" +import { useWslServers } from "../../src/servers/wsl/context" +import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types" + +export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) { + const root = document.getElementById("root") + if (!root) throw new Error("Missing fixture root") + const history = createMemoryHistory() + history.set({ value: "/settings", replace: true, scroll: false }) + render(() => { + const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({ + calls: [], + state: { + runtime: { available: true, version: "2", error: null }, + installed: [], + online: [], + distroProbes: {}, + pendingRestart: false, + job: null, + servers: [ + { + config: { id: "wsl:Ubuntu", distro: "Ubuntu" }, + runtime: + input.mode === "ready" + ? { kind: "ready", url: input.server, password: null } + : input.mode === "failed" + ? { kind: "failed", message: "WSL failed to start" } + : { kind: "stopped" }, + }, + ], + opencodeChecks: { + Ubuntu: { + distro: "Ubuntu", + resolvedPath: "/usr/bin/opencode", + version: "old", + expectedVersion: "current", + matchesDesktop: false, + error: null, + }, + }, + }, + }) + const listeners = new Set<(event: WslServersEvent) => void>() + const publish = () => + listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) })) + const unused = async () => { + throw new Error("Unexpected fixture action") + } + const wsl: WslServersPlatform = { + getState: async () => structuredClone(unwrap(store.state)), + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + probeRuntime: unused, + refreshDistros: unused, + installWsl: unused, + installDistro: unused, + probeAddable: unused, + openTerminal: unused, + addServer: unused, + async installOpencode(distro) { + setStore("calls", (calls) => [...calls, `update:${distro}`]) + setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true }) + publish() + }, + async startServer(id) { + setStore("calls", (calls) => [...calls, `start:${id}`]) + setStore("state", "servers", (server) => server.config.id === id, "runtime", { + kind: "ready", + url: input.server, + password: null, + }) + publish() + }, + async removeServer(id) { + setStore("calls", (calls) => [...calls, `remove:${id}`]) + setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id)) + publish() + }, + } + const platform: Platform = { + platform: "desktop", + os: "windows", + windowID: "settings-wsl-test", + openExternal: () => undefined, + openDirectoryPickerDialog: async () => null, + notify: async () => undefined, + restart: unused, + wslServers: wsl, + } + function Interface() { + const wsl = useWslServers() + const servers = createMemo(() => [ + { type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } }, + ...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] => + item.runtime.kind === "ready" + ? [ + { + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + displayName: item.config.distro, + http: { url: item.runtime.url }, + }, + ] + : [], + ), + ]) + return ( + + } + /> + + ) + } + return ( + + + {store.calls.join(",")} + + + + ) + }, root) +} diff --git a/packages/app/e2e/utils/settings-wsl.html b/packages/app/e2e/utils/settings-wsl.html new file mode 100644 index 000000000000..02fdae1d3a53 --- /dev/null +++ b/packages/app/e2e/utils/settings-wsl.html @@ -0,0 +1,14 @@ + + + + + + +
+ + + diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 6fe3ab39f2dd..045f374034cc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ reuseExistingServer: !built, timeout: 120_000, env: { + VITE_OPENCODE_TEST_FIXTURES: "1", VITE_OPENCODE_SERVER_HOST: serverHost, VITE_OPENCODE_SERVER_PORT: serverPort, }, diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 9149d679f131..9ad1c69478a4 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -1,6 +1,7 @@ import { useDirectoryPicker } from "@/workspaces/selection/picker" import { useServerActionsController } from "@/servers/registry/controller" import { useSettingsCommand } from "@/settings/command" +import { useSettingsSurface } from "@/settings/surface" import { type LocalProject } from "@/shell/state/layout" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" @@ -26,6 +27,7 @@ export function createHomeProjectsController(home: HomeController) { const dialog = useDialog() const language = useLanguage() const openSettings = useSettingsCommand() + const settings = useSettingsSurface() const serverManagement = useServerActionsController() const global = useGlobal() const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} }) @@ -110,8 +112,10 @@ export function createHomeProjectsController(home: HomeController) { }) }, edit: (conn: ServerConnection.Any, project: LocalProject) => { - void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => { - void dialog.show(() => ) + settings.openProject({ + server: ServerConnection.key(conn), + project: project.worktree, + parent: global.servers.list().length > 1 ? "server" : "root", }) }, unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 3c76ec192300..756b719ea42e 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -1,7 +1,8 @@ import { createPromptProjectController } from "@/new-session/project/selector" -import { useSettingsDialog } from "@/settings/command" +import { useSettingsSurface } from "@/settings/surface" import { useSettings } from "@/settings/model" import { useTabs, type DraftTab } from "@/shell/tabs/tabs" +import { useSettingsServers } from "@/settings/servers/inventory" import { useSearchParams } from "@solidjs/router" import { createEffect, createMemo, createResource, untrack } from "solid-js" import { createComposerModel } from "@/composer/model" @@ -16,10 +17,19 @@ export default function NewSessionPage(props: { draftId: string }) { const settings = useSettings() const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>() const tabs = useTabs() - const openWorkspaces = useSettingsDialog("workspaces") + const servers = useSettingsServers() + const settingsSurface = useSettingsSurface() const draftTab = createMemo(() => tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), ) + const openWorkspaces = () => { + const draft = draftTab() + if (servers().length > 1 && draft) { + settingsSurface.openServer(draft.server, "workspaces") + return + } + settingsSurface.open("workspaces") + } const workspace = createNewSessionWorkspaceController({ selectedWorktree: () => draftTab()?.worktree, selectedBranch: () => draftTab()?.branch, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 72e463c693cd..2f7142f2f35b 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -398,6 +398,7 @@ export const dict = { "dialog.server.menu.default": "Set as default", "dialog.server.menu.defaultRemove": "Remove default", "dialog.server.menu.delete": "Delete", + "dialog.server.menu.remove": "Remove", "dialog.server.menu.hide": "Hide from project list", "dialog.server.menu.show": "Show in project list", "dialog.server.current": "Current Server", @@ -944,6 +945,8 @@ export const dict = { "settings.section.desktop": "Desktop", "settings.section.server": "Server", "settings.backToApp": "Back to app", + "settings.backToSettings": "Back to settings", + "settings.backToProjects": "Back to projects", "settings.tab.general": "General", "settings.tab.preferences": "Preferences", "settings.tab.shortcuts": "Shortcuts", @@ -981,6 +984,10 @@ export const dict = { "settings.notifications.description": "Choose when to receive notifications and hear sounds", "settings.shortcuts.description": "Customize shortcuts for common actions", "settings.servers.description": "Manage server connections", + "settings.server.description": "Manage this server’s connection and preferences", + "settings.server.section.connection": "Connection", + "settings.server.preferences.websearch.title": "Third-party search", + "settings.server.preferences.websearch.description": "Select the search provider agents use to search the web", "settings.projects.title": "Projects", "settings.projects.description": "Manage project settings on this server", "settings.projects.empty": "No projects found", @@ -998,12 +1005,25 @@ export const dict = { "dialog.server.authenticate.title": "Authenticate", "project.settings.title": "Edit project", "project.settings.general.description": "Manage project name and appearance", + "project.settings.name.title": "Project name", + "project.settings.name.description": "The name shown for this project throughout OpenCode", + "project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.", + "project.settings.color.description": "Used for the project icon when no custom image is set", + "project.settings.worktree.startup.description": "Runs once after creating a new worktree", + "project.settings.worktree.startup.hint": + "Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.", "project.settings.scripts": "Scripts", "project.settings.scripts.description": "Configure scripts for this project", "project.settings.extensions.description": "View extensions available to this project", "project.settings.extensions.tab.lsps": "LSPs", "project.settings.extensions.added": "Added to this project", "project.settings.extensions.shared": "Shared with all projects", + "project.settings.extensions.empty.mcps.title": "No MCPs yet", + "project.settings.extensions.empty.mcps.description": "MCPs available to OpenCode will appear here", + "project.settings.extensions.empty.plugins.title": "No plugins yet", + "project.settings.extensions.empty.plugins.description": "Plugins available to OpenCode will appear here", + "project.settings.extensions.empty.skills.title": "No skills yet", + "project.settings.extensions.empty.skills.description": "Skills available to OpenCode will appear here", "project.settings.extensions.lsp.detected": "Detected language servers", "project.settings.extensions.lsp.description": "Auto-detected from file types", "project.settings.extensions.setupRequired": "Setup required", diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 50015e1a93a2..76f5263bc258 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -1,6 +1,5 @@ import { createSimpleContext } from "@opencode/ui/context" import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js" -import { createStore } from "solid-js/store" import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry" import { pathKey } from "@/workspaces/path-key" import { useServerHealth } from "@/runtime/server/health" @@ -25,24 +24,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( () => server.list, () => true, ) - const [store, setStore] = createStore({ - settings: { - serverKey: undefined as ServerConnection.Key | undefined, - }, - }) const models = createGlobalModels() - const settingsServer = createMemo(() => { - const list = server.list - return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0] - }) - - createEffect(() => { - const conn = settingsServer() - const key = conn ? ServerConnection.key(conn) : undefined - if (store.settings.serverKey !== key) setStore("settings", "serverKey", key) - }) - const serverCtxs = new Map>() const serverCtxDisposers = new Map void>() @@ -82,17 +65,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( list: () => server.list, health: serverHealth, }, - settings: { - server: { - get key() { - return store.settings.serverKey - }, - selected: settingsServer, - set(key: ServerConnection.Key) { - if (store.settings.serverKey !== key) setStore("settings", "serverKey", key) - }, - }, - }, models, ensureServerCtx(conn: ServerConnection.Any) { return ensureServerCtx(conn) diff --git a/packages/app/src/servers/connect/dialog.tsx b/packages/app/src/servers/connect/dialog.tsx index f24a6f3ae068..dcd7fd64fb05 100644 --- a/packages/app/src/servers/connect/dialog.tsx +++ b/packages/app/src/servers/connect/dialog.tsx @@ -25,11 +25,15 @@ type FormMode = "list" | "add" | "edit" export const DialogServer: Component<{ mode: "add" | "edit" server?: ServerConnection.Http + onSave?: (server: ServerConnection.Http) => void }> = (props) => { const dialog = useDialog() const language = useLanguage() const form = createFormController({ - onSelect: () => dialog.close(), + onSelect: (server) => { + props.onSave?.(server) + dialog.close() + }, }) const [opened, setOpened] = createSignal(false) @@ -133,7 +137,7 @@ export const DialogServer: Component<{ ) } -function createFormController(options: { onSelect?: () => void } = {}) { +function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) { const platform = usePlatform() const server = useServers() const tabs = useTabs() @@ -220,13 +224,14 @@ function createFormController(options: { onSelect?: () => void } = {}) { if (original?.type === "http") { if (normalized === original.http.url) add(connection) if (normalized !== original.http.url) replace(ServerConnection.key(original), connection) + options.onSelect?.(connection) reset() return } reset() add(connection) - options.onSelect?.() + options.onSelect?.(connection) }, })) diff --git a/packages/app/src/servers/registry/controller.test.ts b/packages/app/src/servers/registry/controller.test.ts new file mode 100644 index 000000000000..f35387e6cf31 --- /dev/null +++ b/packages/app/src/servers/registry/controller.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { ServerConnection } from "@/runtime/server/registry" +import { sortServerConnections } from "./controller" + +const server = (url: string): ServerConnection.Http => ({ type: "http", http: { url } }) + +describe("sortServerConnections", () => { + test("places the default first and preserves health and insertion ordering", () => { + const first = server("http://first") + const offline = server("http://offline") + const preferred = server("http://preferred") + const unknown = server("http://unknown") + const result = sortServerConnections({ + servers: [first, offline, preferred, unknown], + health: { + [ServerConnection.key(first)]: { healthy: true }, + [ServerConnection.key(offline)]: { healthy: false }, + }, + defaultKey: ServerConnection.key(preferred), + }) + + expect(result).toEqual([preferred, first, unknown, offline]) + }) +}) diff --git a/packages/app/src/servers/registry/controller.ts b/packages/app/src/servers/registry/controller.ts index b3ecf6350452..5cfdb0bcec37 100644 --- a/packages/app/src/servers/registry/controller.ts +++ b/packages/app/src/servers/registry/controller.ts @@ -1,10 +1,8 @@ -import { useNavigate } from "@solidjs/router" import { createMemo, createResource } from "solid-js" import { useGlobal } from "@/runtime/server/runtime" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { ServerConnection, useServers } from "@/runtime/server/registry" -import { useSettings } from "@/settings/model" import { useTabs } from "@/shell/tabs/tabs" import { type ServerHealth } from "@/runtime/server/health" import { showToast } from "@/shell/notifications/toast" @@ -48,6 +46,27 @@ function useDefaultServer() { } } +export function sortServerConnections(input: { + servers: ServerConnection.Any[] + health: Record + defaultKey: ServerConnection.Key | null +}) { + const order = new Map(input.servers.map((item, index) => [item, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return input.servers.slice().sort((a, b) => { + const preferred = + Number(ServerConnection.key(b) === input.defaultKey) - Number(ServerConnection.key(a) === input.defaultKey) + if (preferred !== 0) return preferred + const health = rank(input.health[ServerConnection.key(a)]) - rank(input.health[ServerConnection.key(b)]) + if (health !== 0) return health + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) +} + export function useServerActionsController() { const server = useServers() const tabs = useTabs() @@ -75,8 +94,8 @@ export function useServerActionsController() { const conn = server.list.find((item) => ServerConnection.key(item) === key) return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn) }, - isHidden: server.isHidden, - setHidden: server.setHidden, + isHidden: (key: ServerConnection.Key) => server.isHidden(key), + setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden), }, } } @@ -86,27 +105,16 @@ export type ServerActionsController = ReturnType server.list) - const sorted = createMemo(() => { - const raw = items() - const list = raw - if (!list.length) return list - const order = new Map(list.map((item, index) => [item, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 - } - return list.slice().sort((a, b) => { - const diff = - rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) - }) - }) + const sorted = createMemo(() => + sortServerConnections({ + servers: items(), + health: global.servers.health, + defaultKey: actions.defaults.key(), + }), + ) return { collection: { diff --git a/packages/app/src/servers/registry/row-menu.tsx b/packages/app/src/servers/registry/row-menu.tsx index d8e7b6755c02..322b41a0a63f 100644 --- a/packages/app/src/servers/registry/row-menu.tsx +++ b/packages/app/src/servers/registry/row-menu.tsx @@ -43,7 +43,7 @@ export function serverMenuLabels(language: ReturnType) { edit: language.t("dialog.server.menu.edit"), default: language.t("dialog.server.menu.default"), defaultRemove: language.t("dialog.server.menu.defaultRemove"), - delete: language.t("dialog.server.menu.delete"), + remove: language.t("dialog.server.menu.remove"), hide: language.t("dialog.server.menu.hide"), show: language.t("dialog.server.menu.show"), } @@ -104,7 +104,7 @@ export const ServerRowMenuView: Component<{ - {props.labels.delete} + {props.labels.remove} diff --git a/packages/app/src/servers/wsl/settings.tsx b/packages/app/src/servers/wsl/settings.tsx index 3324b7233668..36c9ec4b6269 100644 --- a/packages/app/src/servers/wsl/settings.tsx +++ b/packages/app/src/servers/wsl/settings.tsx @@ -16,12 +16,13 @@ import { showToast } from "@/shell/notifications/toast" import { DialogAddWslServer } from "./dialog" import { useWslServers } from "./context" import { wslOpencodeAction, wslRuntimeRetryable } from "./model" +import type { WslServerItem } from "./types" export function isWslServer(server: ServerConnection.Any) { return server.type === "sidecar" && server.variant === "wsl" } -export function AddServerMenu(props: { onAddServer: () => void }) { +export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) { const platform = usePlatform() const dialog = useDialog() const language = useLanguage() @@ -32,15 +33,41 @@ export function AddServerMenu(props: { onAddServer: () => void }) { - {language.t("dialog.server.add.button")} - + + {language.t("dialog.server.add.button")} + + } + > + } + aria-label={language.t("dialog.server.add.button")} + onClick={props.onAddServer} + /> + } > - - {language.t("dialog.server.add.button")} - + + {language.t("dialog.server.add.button")} + + } + > + } + aria-label={language.t("dialog.server.add.button")} + /> + {language.t("dialog.server.add.button")} @@ -66,7 +93,7 @@ export function useFilteredWslServers(filter: Accessor) { export function WslServerSettings(props: { domain: Pick - servers: ReturnType + servers: Accessor }) { const platform = usePlatform() const language = useLanguage() @@ -154,7 +181,9 @@ export function WslServerSettings(props: { - remove(key)}>{language.t("dialog.server.menu.delete")} + remove(key)}> + {language.t("dialog.server.menu.remove")} + diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 879199dc7162..25f54c44ac6d 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -1,5 +1,4 @@ import type { SessionInfo } from "@opencode/client/promise" -import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" @@ -10,12 +9,14 @@ import { useNavigate } from "@solidjs/router" import { createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { useServer } from "@/runtime/server/current" +import { ServerConnection, useServers } from "@/runtime/server/registry" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers" import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout" import { tabKey, useTabs } from "@/shell/tabs/tabs" import { useSettings } from "@/settings/model" +import { useSettingsSurface } from "@/settings/surface" import { pathKey } from "@/workspaces/path-key" import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths" import { sessionHref } from "@/shell/routes/session" @@ -41,10 +42,11 @@ export function SessionProjectMenu(props: { showProjectIcon: boolean }) { const server = useServer() + const servers = useServers() const language = useLanguage() - const dialog = useDialog() const platform = usePlatform() const layout = useLayout() + const settingsSurface = useSettingsSurface() const navigate = useNavigate() const [state, setState] = createStore({ open: false, @@ -64,11 +66,14 @@ export function SessionProjectMenu(props: { }), ) } - const openProjectSettings = async () => { + const openProjectSettings = () => { const current = props.project if (!current) return - const { DialogEditProject } = await import("@/settings/workspaces/project-dialog") - dialog.push(() => ) + settingsSurface.openProject({ + server: ServerConnection.key(server.conn), + project: current.worktree, + parent: servers.list.length > 1 ? "server" : "root", + }) } return ( @@ -191,7 +196,7 @@ export function SessionProjectMenu(props: { - void openProjectSettings()}> + {language.t("project.settings.title")} diff --git a/packages/app/src/settings/command.tsx b/packages/app/src/settings/command.tsx index 4258c7d3d3f4..1e8601d5dbe1 100644 --- a/packages/app/src/settings/command.tsx +++ b/packages/app/src/settings/command.tsx @@ -1,8 +1,9 @@ import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSettingsSurface } from "./surface" +import type { SettingsRootTab } from "./surface" -export function useSettingsDialog(defaultValue?: string) { +export function useSettingsDialog(defaultValue?: SettingsRootTab) { const settings = useSettingsSurface() return () => settings.open(defaultValue) } diff --git a/packages/app/src/settings/general/controllers.ts b/packages/app/src/settings/general/controllers.ts index 8d878c432232..621231fc56b5 100644 --- a/packages/app/src/settings/general/controllers.ts +++ b/packages/app/src/settings/general/controllers.ts @@ -1,4 +1,5 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ConfigPreferences, ConfigShellOption, ConfigUpdatePreferencesInput } from "@opencode/client/promise" import type { ColorScheme } from "@opencode/ui/theme/context" import { useTheme } from "@opencode/ui/theme/context" import { @@ -14,32 +15,104 @@ import { useSettings, } from "@/settings/model" import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound" -import { createSoundPreviewController, type ShellOption } from "./behavior" +import { createSoundPreviewController } from "./behavior" import { ServerConnection } from "@/runtime/server/registry" import { useServerCtx } from "@/runtime/server/runtime" +import { useLanguage } from "@/runtime/i18n/language" +import { showToast } from "@/shell/notifications/toast" export { createShellOptions, createSoundPreviewController } from "./behavior" export type { ShellOption, ShellSelectOption } from "./behavior" -export function createShellSettingsController(server: Accessor) { +export function createServerPreferencesController(server: Accessor) { + const language = useLanguage() const serverCtx = useServerCtx(server) - const [shells] = createResource( - async () => { - // TODO: Dax is considering the V2 shell discovery and config update APIs. - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, + const source = () => { + const current = server() + return current && ServerConnection.key(current) + } + const [preferences, preferencesActions] = createResource( + source, + () => + serverCtx() + ?.sdk.api.config.preferences() + .catch(() => ({})) ?? Promise.resolve({}), + { initialValue: {} }, + ) + const [shells] = createResource( + source, + () => + serverCtx() + ?.sdk.api.config.shells() + .catch(() => []) ?? Promise.resolve([]), + { initialValue: [] }, ) - const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "") + const [providers] = createResource( + source, + () => + serverCtx() + ?.sdk.api.websearch.providers() + .then((result) => result.data) + .catch(() => []) ?? Promise.resolve([]), + { initialValue: [] }, + ) + + const update = async (patch: ConfigUpdatePreferencesInput) => { + const context = serverCtx() + if (!context) return + const previous = preferences.latest + preferencesActions.mutate({ + ...previous, + ...(patch.shell === undefined ? {} : { shell: patch.shell ?? undefined }), + ...(patch.websearch === undefined ? {} : { websearch: patch.websearch ?? undefined }), + }) + await context.sdk.api.config + .updatePreferences(patch) + .then(preferencesActions.mutate) + .catch((error: unknown) => { + preferencesActions.mutate(previous) + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + }) + } + + const websearchOptions = createMemo(() => { + const options = (providers.latest ?? []).map((provider) => ({ value: provider.id, label: provider.name })) + const selected = preferences.latest.websearch + const configured = selected && selected.provider !== "random" ? selected.provider : undefined + return [ + { value: "random" as const, label: language.t("session.websearch.any") }, + ...options, + ...(configured && !options.some((option) => option.value === configured) + ? [{ value: configured, label: configured }] + : []), + { value: false as const, label: language.t("session.websearch.disable") }, + ] + }) + const websearchCurrent = createMemo(() => { + const selection = preferences.latest.websearch + const value = selection === false ? false : (selection?.provider ?? "random") + return websearchOptions().find((option) => option.value === value) ?? websearchOptions()[0] + }) return { - shells: () => shells.latest, - current, - select: (value: string) => { - if (value === current()) return - // TODO: Dax is considering the V2 shell discovery and config update APIs. - // void serverSync.updateConfig({ shell: value }) + shell: { + shells: () => shells.latest, + current: () => preferences.latest.shell ?? "", + select: (value: string) => { + if (value === (preferences.latest.shell ?? "")) return + void update({ shell: value || null }) + }, + }, + websearch: { + options: websearchOptions, + current: websearchCurrent, + select: (value: string | false) => { + void update({ websearch: value === false ? false : { provider: value } }) + }, }, } } @@ -139,6 +212,6 @@ export function createSoundSettingsController() { } } -export type ShellSettingsController = ReturnType +export type ShellSettingsController = ReturnType["shell"] export type AppearanceSettingsController = ReturnType export type SoundSettingsController = ReturnType diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 304957f0b68e..2f6c02ab1643 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -21,12 +21,10 @@ import { SettingsRow } from "@/settings/row" import { createAppearanceSettingsController, createShellOptions, - createShellSettingsController, type AppearanceSettingsController, type ShellSettingsController, } from "./controllers" import "@/settings/settings.css" -import { ServerConnection } from "@/runtime/server/registry" const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] const fontSettings = { @@ -97,7 +95,7 @@ const WorkspaceDestinationSetting: Component = () => { ) } -const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { +export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const language = useLanguage() const options = createMemo(() => createShellOptions({ @@ -295,15 +293,12 @@ const LanguageSetting = () => { ) } -export const SettingsGeneral: Component<{ - server?: ServerConnection.Any -}> = (props) => { +export const SettingsGeneral: Component = () => { const language = useLanguage() const platform = usePlatform() const settings = useSettings() const mobile = createMediaQuery("(max-width: 767px)") const updater = useUpdaterAction() - const shell = createShellSettingsController(() => props.server) const desktop = createMemo(() => platform.platform === "desktop") const [pinchZoom, { mutate: setPinchZoom }] = createResource( @@ -328,7 +323,6 @@ export const SettingsGeneral: Component<{ - diff --git a/packages/app/src/settings/models/models.tsx b/packages/app/src/settings/models/models.tsx index 847d4b93d04d..4064d874d029 100644 --- a/packages/app/src/settings/models/models.tsx +++ b/packages/app/src/settings/models/models.tsx @@ -12,7 +12,6 @@ import { useModels } from "@/providers/models/models" import { useServerSDK } from "@/runtime/server/client" import { popularProviders } from "@/providers/catalog/providers" import { Persist, persisted } from "@/runtime/persistence/storage" -import { InlineServerSelect } from "@/settings/server-select" import { SettingsList } from "@/settings/list" import { SettingsRow } from "@/settings/row" import "@/settings/settings.css" @@ -65,7 +64,6 @@ export const SettingsModels: Component = () => {

{language.t("settings.models.title")}

{language.t("settings.models.description")}
-
-
@@ -78,18 +76,20 @@ export const SettingsExtensions: Component = () => {
- + {language.t("settings.extensions.availableAll")} - {language.t("settings.extensions.manageConfig")} + + {language.t("settings.extensions.manageConfig")} +
-
+
{(item) => (
- {item.name} + {item.name}
handleMcpToggle(item, checked)} hideLabel> {item.name} @@ -104,18 +104,20 @@ export const SettingsExtensions: Component = () => {
- + {language.t("settings.extensions.availableAll")} - {language.t("settings.extensions.manageConfig")} + + {language.t("settings.extensions.manageConfig")} +
-
+
{(plugin) => (
- {plugin.name} + {plugin.name}
)} @@ -127,23 +129,23 @@ export const SettingsExtensions: Component = () => {
- + {language.t("settings.extensions.availableAll")} {language.t("settings.extensions.addSkills")}
-
+
{(skill) => (
- {skill.name} + {skill.name}
)} diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index 4c32fcbc4c03..54016b6c623e 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -9,8 +9,6 @@ import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog" -import { SettingsServerScope } from "@/settings/server-scope" -import { InlineServerSelect } from "@/settings/server-select" import { SettingsList } from "@/settings/list" import "@/settings/settings.css" @@ -44,11 +42,7 @@ export const SettingsProviders: Component<{ const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ( - - - - )) + void dialog.show(() => ) } const connected = createMemo(() => { @@ -134,7 +128,6 @@ export const SettingsProviders: Component<{

{language.t("settings.providers.title")}

{language.t("settings.providers.description")}
-
diff --git a/packages/app/src/settings/server-scope.tsx b/packages/app/src/settings/server-scope.tsx index 428943b9c6da..1db9c94ed6fb 100644 --- a/packages/app/src/settings/server-scope.tsx +++ b/packages/app/src/settings/server-scope.tsx @@ -1,22 +1,8 @@ -import { type ParentProps, Show } from "solid-js" -import { useGlobal } from "@/runtime/server/runtime" +import { type ParentProps } from "solid-js" import { ModelsProvider } from "@/providers/models/models" import { ServerProvider } from "@/runtime/server/current" import { ServerConnection } from "@/runtime/server/registry" -export function SettingsServerScope(props: ParentProps<{ directory?: string }>) { - const global = useGlobal() - return ( - - {(server) => ( - - {props.children} - - )} - - ) -} - export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) { return ( diff --git a/packages/app/src/settings/server-select.tsx b/packages/app/src/settings/server-select.tsx deleted file mode 100644 index 82941102d37f..000000000000 --- a/packages/app/src/settings/server-select.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Show, createMemo, type Component } from "solid-js" -import { Select } from "@opencode/ui/select" -import { useGlobal } from "@/runtime/server/runtime" -import { ServerConnection, serverName } from "@/runtime/server/registry" - -const allServers = { type: "all" } as const -type ServerOption = ServerConnection.Any | typeof allServers - -export const InlineServerSelect: Component<{ - all?: { - label: string - selected: () => boolean - onSelect: () => void - } - onServerSelect?: () => void -}> = (props) => { - const global = useGlobal() - const options = createMemo(() => [...(props.all ? [allServers] : []), ...global.servers.list()]) - const current = () => (props.all?.selected() ? allServers : global.settings.server.selected()) - - return ( - 1}> - String(option.value)} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && props.controller.select(option.value)} + /> + ) +} - const filtered = createMemo(() => { - const items = controller.collection.items().filter((item) => !isWslServer(item)) - 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 = () => { - void dialog.push(() => ) - } - - const openEdit = (server: ServerConnection.Http) => { - void dialog.push(() => ) - } +export const SettingsServerGeneral: Component<{ + entry: SettingsServer + nested?: boolean + onAddServer?: () => void + onServerChange?: (server: ServerConnection.Any) => void +}> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const controller = useServerCollectionController() + const health = createMemo(() => controller.collection.health()[props.entry.key]) + const edit = (server: ServerConnection.Http) => + void dialog.push(() => ) return ( <> -
+
-

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

- {language.t("settings.servers.description")} +

+ {props.nested ? props.entry.name : language.t("settings.section.server")} +

+ + {language.t(props.nested ? "settings.server.description" : "settings.servers.description")} +
- + + props.onAddServer?.()} /> +
- - -
-
- 0 || wslServers().length > 0} - fallback={ -
- {store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")} - - "{store.filter}" - -
- } - > +
+
+

{language.t("settings.server.section.connection")}

- - - {(item) => { - const key = ServerConnection.key(item) - const health = () => controller.collection.health()[key] - const isDefault = () => controller.defaults.key() === key - return ( -
-
- -
- {serverName(item)} - - v{health()?.version} + + {(server) => ( +
+
+ +
+ + {serverName(server()) || props.entry.key} + + + {server().http.url} + +
+
+
+ + {language.t("dialog.server.status.default")} +
-
- - {language.t("dialog.server.status.default")} - - -
-
- ) - }} - + )} + + } + > + {(item) => [item()]} />} + +
+ + + {(server) => }
) } + +function ServerPreferences(props: { server: ServerConnection.Any }) { + const language = useLanguage() + const preferences = createServerPreferencesController(() => props.server) + return ( +
+

{language.t("settings.tab.preferences")}

+ + + + +
+ ) +} diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index efd81651ffa2..563e9fe15dea 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -69,6 +69,56 @@ gap: 16px; } +.settings-nav-groups { + display: flex; + width: 100%; + flex-direction: column; + gap: 16px; +} + +.settings-nav-group { + display: flex; + width: 100%; + flex-direction: column; + gap: 4px; +} + +.settings-nav-group-header { + display: flex; + height: 28px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-inline: 6px; + border-radius: 4px; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); +} + +.settings-nav-group-header [data-component="icon-button-v2"] { + opacity: 0; +} + +.settings-nav-group-header:focus-within [data-component="icon-button-v2"] { + opacity: 1; +} + +.settings-nav-group-header:focus-within { + background: var(--v2-background-bg-layer-03); +} + +@media (hover: hover) { + .settings-nav-group-header:hover { + background: var(--v2-background-bg-layer-03); + } + + .settings-nav-group-header:hover [data-component="icon-button-v2"] { + opacity: 1; + } +} + .settings-mobile-nav { display: none; } @@ -77,6 +127,13 @@ min-width: 0; } +.settings-mobile-actions { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + .settings-mobile-menu-trigger > span { overflow: hidden; text-overflow: ellipsis; @@ -874,18 +931,6 @@ color: var(--v2-text-text-base); } -.settings-tab-body.settings-servers { - gap: 0; -} - -.settings-servers [data-component="settings-list"] { - display: flex; - flex-direction: column; - gap: 0; - padding: 20px; - border-radius: 6px; -} - .settings-servers-row { display: flex; align-items: center; @@ -893,6 +938,18 @@ gap: 16px; } +.settings-server-connection [data-component="settings-list"] { + padding-inline: 16px; +} + +.settings-server-connection .settings-servers-row { + padding-block: 20px; +} + +.settings-server-connection .settings-servers-lead { + gap: 4px; +} + .settings-servers-row:not(:last-child) { padding-bottom: 16px; margin-bottom: 16px; @@ -937,24 +994,6 @@ color: var(--v2-text-text-muted); } -.settings-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: var(--line-height-compact); - color: var(--v2-text-text-muted); - text-align: center; -} - -.settings-servers-status-filter { - color: var(--v2-text-text-base); -} - .settings-tab-body.settings-workspaces { gap: 0; } @@ -1331,6 +1370,24 @@ padding-top: 24px; } +.settings-extension-list { + background: var(--v2-background-bg-layer-01); +} + +.settings-extension-heading, +.settings-extension-name { + color: var(--text-strong); +} + +.settings-extension-link { + color: var(--v2-text-text-accent) !important; + text-decoration: underline; +} + +.settings-extension-link:hover { + color: var(--v2-text-text-accent-hover) !important; +} + .settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { display: none; } diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 688d93404109..32a74c25c7b2 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -1,10 +1,17 @@ -import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js" -import { createStore } from "solid-js/store" import { Tabs } from "@opencode/ui/tabs" -import { Icon } from "@opencode/ui/icon" -import { Menu } from "@opencode/ui/menu" -import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { createEffect, createMemo, onMount, Show, Switch, Match } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" +import { useLayout } from "@/shell/state/layout" +import { useTabs } from "@/shell/tabs/tabs" +import { useGlobal, useServerCtx } from "@/runtime/server/runtime" +import { ServerConnection } from "@/runtime/server/registry" +import type { LocalProject } from "@/shell/state/layout" +import { useServerCollectionController } from "@/servers/registry/controller" +import { AddServerMenu } from "@/servers/wsl/settings" +import { DialogServer } from "@/servers/connect/dialog" +import { LocationProvider } from "@/workspaces/location" import { SettingsGeneral } from "./general/general" import { SettingsAppearance } from "./appearance/appearance" import { SettingsExperimental } from "./experimental/experimental" @@ -12,233 +19,414 @@ import { SettingsKeybinds } from "./keybinds/keybinds" import { SettingsNotifications } from "./notifications/notifications" import { SettingsProviders } from "./providers/providers" import { SettingsModels } from "./models/models" -import { SettingsServers } from "./servers/servers" +import { SettingsServerGeneral } from "./servers/servers" +import { useSettingsServers, type SettingsServer } from "./servers/inventory" import { SettingsWorkspaces } from "./workspaces/workspaces" import { SettingsProjects } from "./workspaces/projects" import { SettingsExtensions } from "./providers/extensions" import { SettingsAbout } from "./about/about" -import { SettingsServerScope } from "./server-scope" -import { useDialog } from "@opencode/ui/context/dialog" -import { useLayout } from "@/shell/state/layout" -import { useTabs } from "@/shell/tabs/tabs" -import { useGlobal, useServerCtx } from "@/runtime/server/runtime" -import { ServerConnection, useServers } from "@/runtime/server/registry" +import { SettingsServerDataScope } from "./server-scope" +import { SettingsNavigation, type SettingsNavGroup } from "./navigation" +import { SettingsProjectGeneral } from "./workspaces/project" +import { ProjectSettingsExtensions } from "./workspaces/project-extensions" import { useSettingsSurface } from "./surface" import "@/settings/settings.css" -const sections = [ - [ - { value: "general", icon: "sliders", label: "settings.tab.preferences" }, - { value: "appearance", icon: "appearance", label: "settings.general.section.appearance" }, - { value: "notifications", icon: "notifications", label: "settings.tab.notifications" }, - { value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" }, - ], - [ - { value: "projects", icon: "folder", label: "settings.tab.projects" }, - { value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" }, - { value: "providers", icon: "providers", label: "settings.providers.title" }, - { value: "models", icon: "models", label: "settings.models.title" }, - { value: "extensions", icon: "extensions", label: "settings.tab.extensions" }, - { value: "servers", icon: "server", label: "status.popover.tab.servers" }, - ], +const rootClientTabs = [ + { value: "general", icon: "sliders", label: "settings.tab.preferences" }, + { value: "appearance", icon: "appearance", label: "settings.general.section.appearance" }, + { value: "notifications", icon: "notifications", label: "settings.tab.notifications" }, + { value: "shortcuts", icon: "keyboard", label: "settings.tab.shortcuts" }, +] as const + +const serverTabs = [ + { value: "projects", icon: "folder", label: "settings.tab.projects" }, + { value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" }, + { value: "providers", icon: "providers", label: "settings.providers.title" }, + { value: "models", icon: "models", label: "settings.models.title" }, + { value: "extensions", icon: "extensions", label: "settings.tab.extensions" }, +] as const + +const trailingTabs = [ [{ value: "experimental", icon: "flask", label: "settings.tab.experimental" }], [{ value: "about", icon: "info", label: "settings.tab.about" }], ] as const -export const SettingsScreen: Component = () => { - const language = useLanguage() - const dialog = useDialog() +const nestedServerTabs = [ + { value: "general", icon: "server", label: "settings.general.section.general" }, + ...serverTabs, +] as const + +const nestedProjectTabs = [ + { value: "general", icon: "folder", label: "settings.general.section.general" }, + { value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" }, + { value: "extensions", icon: "extensions", label: "settings.tab.extensions" }, +] as const + +export function SettingsScreen() { const surface = useSettingsSurface() - const layout = useLayout() - const servers = useServers() - const tabs = useTabs() + const dialog = useDialog() + const servers = useSettingsServers() const global = useGlobal() - const [state, setState] = createStore({ worktreeFilterReset: 0 }) let root: HTMLDivElement | undefined + let viewType = surface.view().type - onMount(() => { - root?.focus({ preventScroll: true }) + onMount(() => root?.focus({ preventScroll: true })) + createEffect(() => { + const next = surface.view().type + if (next === viewType) return + viewType = next + queueMicrotask(() => root?.focus({ preventScroll: true })) }) - const server = createMemo(() => { - const route = surface.route() - switch (route.type) { - case "draft": { - const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) - return servers.list.find((item) => ServerConnection.key(item) === draft?.server) - } - case "session": - return servers.list.find((item) => ServerConnection.key(item) === route.server) - case "home": - return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server) + const connection = (key: string) => servers().find((item) => item.key === key) + const project = (server: ServerConnection.Any, directory: string) => { + const context = global.ensureServerCtx(server) + const value = + context.projects.list().find((item) => item.worktree === directory) ?? + context.sync.data.project.find((item) => item.worktree === directory) + return value ? { expanded: false, ...value } : undefined + } + const targetServer = createMemo(() => { + const view = surface.view() + if (view.type === "root") return undefined + return connection(view.server) + }) + const targetProject = createMemo(() => { + const view = surface.view() + const server = targetServer() + if (view.type !== "project" || !server) return undefined + return server.connection && project(server.connection, view.project) + }) + createEffect(() => { + const view = surface.view() + if (view.type === "root") return + const target = targetServer() + if (!target) { + surface.back() + return } + if (view.type === "project" && !target.connection) surface.replaceServer(target.key) }) - const serverCtx = useServerCtx(server) - createEffect(() => { - const current = server() - if (current) global.settings.server.set(ServerConnection.key(current)) + const view = surface.view() + if (view.type !== "server" || servers().length !== 1) return + surface.open(view.tab === "general" ? "servers" : view.tab) }) - const directory = createMemo(() => { - const selected = global.settings.server.selected() - const current = server() - if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return + return ( +
{ + if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return + event.preventDefault() + surface.back() + }} + > + + {} + + {(server) => } + + + + {(server) => ( + {(project) => } + )} + + + +
+ ) +} + +function RootSettings() { + const language = useLanguage() + const dialog = useDialog() + const surface = useSettingsSurface() + const layout = useLayout() + const tabs = useTabs() + const servers = useServerCollectionController() + const inventory = useSettingsServers() + const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const list = servers.collection.items + const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined)) + const single = createMemo(() => singleEntry()?.connection) + const multiple = createMemo(() => inventory().length > 1) + const ordered = createMemo(() => { + const order = new Map(list().map((server, index) => [ServerConnection.key(server), index])) + return inventory().toSorted((a, b) => { + const preferred = Number(b.key === servers.defaults.key()) - Number(a.key === servers.defaults.key()) + if (preferred) return preferred + return (order.get(a.key) ?? list().length) - (order.get(b.key) ?? list().length) + }) + }) + const sourceServer = createMemo(() => { + const route = surface.route() + if (route.type === "session") return connectionFor(list(), route.server) + if (route.type === "draft") { + const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) + return connectionFor(list(), draft?.server) + } + return connectionFor(list(), layout.home.selection().server) + }) + const sourceCtx = useServerCtx(sourceServer) + const sourceDirectory = createMemo(() => { const route = surface.route() if (route.type === "draft") { const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) return draft?.type === "draft" ? draft.directory : undefined } - if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory + if (route.type === "session") return sourceCtx()?.data.session.get(route.sessionId)?.location.directory return undefined }) + const addServer = () => + void dialog.push(() => ( + surface.openServer(ServerConnection.key(server))} /> + )) + const groups = createMemo(() => [ + { items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) }, + ...(multiple() + ? [ + { + label: language.t("status.popover.tab.servers"), + action: , + items: ordered().map((server) => ({ + value: `server:${server.key}`, + icon: "server" as const, + label: server.name, + })), + }, + ] + : [ + { + items: [ + ...serverTabs.map((item) => ({ ...item, label: language.t(item.label), disabled: !single() })), + { value: "servers", icon: "server" as const, label: language.t("settings.section.server") }, + ], + }, + ]), + ...trailingTabs.map((items) => ({ items: items.map((item) => ({ ...item, label: language.t(item.label) })) })), + ]) - const showProviders = () => { - dialog.close() - surface.open("providers") + createEffect(() => { + const view = surface.view() + if (view.type !== "root" || !multiple()) return + if (["projects", "workspaces", "providers", "models", "extensions", "servers"].includes(view.tab)) + surface.open("general") + }) + + const change = (value: string) => { + if (value.startsWith("server:")) { + surface.openServer(value.slice("server:".length)) + return + } + if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1) + surface.select(value) } return ( -
{ - if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return - event.preventDefault() - surface.close() - }} + surface.close()} + onChange={change} + mobileAction={multiple() ? : undefined} > - void startTransition(() => surface.open(value))} - class="settings" - > -
- - - - - {language.t( - sections.flat().find((section) => section.value === surface.tab())?.label ?? - "settings.tab.preferences", - )} - - - - - event.stopPropagation()}> - void startTransition(() => surface.open(value))} - > - - {(group, index) => ( - <> - 0}> - - - - {(section) => ( - { - if (section.value === "workspaces") - setState("worktreeFilterReset", (value) => value + 1) - }} - > - - {language.t(section.label)} - - )} - - - )} - - - - - -
- -
- -
- - {(group) => ( -
- - {(section) => ( - { - if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1) - }} - > - - {language.t(section.label)} - - )} - -
- )} -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - state.worktreeFilterReset} - /> + + + + + + + + + + + + + + + + + + + + {(server) => ( + + + + surface.openProject({ + server: ServerConnection.key(server), + project: project.worktree, + parent: "root", + }) + } + /> + + + state.worktreeFilterReset} + /> + + + surface.select("providers")} /> + + + + + + + + + )} + + + {(entry) => ( + + - - + )} + +
+ ) +} + +function ServerSettings(props: { entry: SettingsServer }) { + const language = useLanguage() + const surface = useSettingsSurface() + const tabs = useTabs() + const serverCtx = useServerCtx(() => props.entry.connection) + const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const groups = createMemo(() => [ + { + items: nestedServerTabs.map((item) => ({ + ...item, + label: item.value === "general" ? props.entry.name : language.t(item.label), + disabled: item.value !== "general" && !props.entry.connection, + })), + }, + ]) + createEffect(() => { + if (!props.entry.connection && surface.view().tab !== "general") surface.select("general") + }) + const change = (value: string) => { + if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1) + surface.select(value) + } + const activeDirectory = createMemo(() => { + const route = surface.route() + if (route.type === "session" && route.server === props.entry.key) + return serverCtx()?.data.session.get(route.sessionId)?.location.directory + if (route.type !== "draft") return undefined + const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) + return draft?.type === "draft" && draft.server === props.entry.key ? draft.directory : undefined + }) + + return ( + surface.back()} + onChange={change} + > + + surface.replaceServer(ServerConnection.key(server))} + /> + + + {(server) => ( + + + + surface.openProject({ + server: props.entry.key, + project: project.worktree, + parent: "server", + }) + } + /> + + + state.worktreeFilterReset} + /> + + + surface.select("providers")} /> + + + + + + + + + )} + + + ) +} + +function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) { + const language = useLanguage() + const surface = useSettingsSurface() + const tabs = useTabs() + const serverCtx = useServerCtx(() => props.server) + const groups = createMemo(() => [ + { items: nestedProjectTabs.map((item) => ({ ...item, label: language.t(item.label) })) }, + ]) + const activeDirectory = createMemo(() => { + const route = surface.route() + if (route.type === "session" && route.server === ServerConnection.key(props.server)) + return serverCtx()?.data.session.get(route.sessionId)?.location.directory + if (route.type !== "draft") return undefined + const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) + return draft?.type === "draft" && draft.server === ServerConnection.key(props.server) ? draft.directory : undefined + }) + return ( + + + surface.back()} + onChange={(value) => surface.select(value)} + > + + surface.replaceServer(ServerConnection.key(props.server))} + /> - - + + - + - - - - - -
+ + + ) } + +function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) { + return list.find((item) => ServerConnection.key(item) === key) +} diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index c2188e39c711..2ab0410d01ba 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -3,6 +3,75 @@ import { createEffect, on } from "solid-js" import { createSimpleContext } from "@opencode/ui/context" import { useLayout, type LayoutRoute } from "@/shell/state/layout" import { useCommand } from "@/shell/commands/command" +import { useSettingsServers } from "./servers/inventory" + +export type SettingsRootTab = + | "general" + | "appearance" + | "notifications" + | "shortcuts" + | "projects" + | "workspaces" + | "providers" + | "models" + | "extensions" + | "servers" + | "experimental" + | "about" + +export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions" +export type SettingsProjectTab = "general" | "workspaces" | "extensions" + +export type SettingsView = + | { type: "root"; tab: SettingsRootTab } + | { type: "server"; server: string; tab: SettingsServerTab } + | { + type: "project" + server: string + project: string + tab: SettingsProjectTab + parent: "root" | "server" + } + +const rootTabs: Record = { + general: true, + appearance: true, + notifications: true, + shortcuts: true, + projects: true, + workspaces: true, + providers: true, + models: true, + extensions: true, + servers: true, + experimental: true, + about: true, +} +const serverTabs: Record = { + general: true, + projects: true, + workspaces: true, + providers: true, + models: true, + extensions: true, +} +const projectTabs: Record = { + general: true, + workspaces: true, + extensions: true, +} + +function isRootTab(value: string): value is SettingsRootTab { + return value in rootTabs +} + +function isServerTab(value: string): value is SettingsServerTab { + return value in serverTabs +} + +function isProjectTab(value: string): value is SettingsProjectTab { + return value in projectTabs +} export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({ name: "SettingsSurface", @@ -11,11 +80,13 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr const navigate = useNavigate() const layout = useLayout() const command = useCommand() + const servers = useSettingsServers() const location = useLocation<{ - settings?: { route: Exclude; tab: string } + settings?: { route: Exclude; view: SettingsView } }>() const open = () => layout.route().type === "settings" const source = () => location.state?.settings?.route ?? { type: "home" as const } + const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" } let focus: HTMLElement | undefined createEffect( @@ -33,17 +104,78 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr return { active: open, route: source, - tab: () => location.state?.settings?.tab ?? "general", - open(tab = "general") { + view, + open(tab: SettingsRootTab = "general") { const route = layout.route() if (route.type !== "settings") { if (document.activeElement instanceof HTMLElement) focus = document.activeElement } navigate("/settings", { replace: open(), - state: { settings: { route: route.type === "settings" ? source() : route, tab } }, + state: { settings: { route: route.type === "settings" ? source() : route, view: { type: "root", tab } } }, + }) + }, + openServer(server: string, tab: SettingsServerTab = "general") { + const route = layout.route() + if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement + navigate("/settings", { + replace: false, + state: { + settings: { route: route.type === "settings" ? source() : route, view: { type: "server", server, tab } }, + }, }) }, + replaceServer(server: string, tab: SettingsServerTab = "general") { + navigate("/settings", { + replace: true, + state: { settings: { route: source(), view: { type: "server", server, tab } } }, + }) + }, + openProject(input: { server: string; project: string; parent: "root" | "server"; tab?: SettingsProjectTab }) { + const route = layout.route() + if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement + navigate("/settings", { + replace: false, + state: { + settings: { + route: route.type === "settings" ? source() : route, + view: { + type: "project", + ...input, + parent: servers().length > 1 ? "server" : "root", + tab: input.tab ?? "general", + }, + }, + }, + }) + }, + select(tab: string) { + const current = view() + const next: SettingsView = + current.type === "root" && isRootTab(tab) + ? { ...current, tab } + : current.type === "server" && isServerTab(tab) + ? { ...current, tab } + : current.type === "project" && isProjectTab(tab) + ? { ...current, tab } + : current + navigate("/settings", { + replace: true, + state: { settings: { route: source(), view: next } }, + }) + }, + back() { + const current = view() + if (current.type === "root") { + command.trigger("common.goBack") + return + } + const parent: SettingsView = + current.type === "server" || current.parent === "root" + ? { type: "root", tab: current.type === "server" ? "general" : "projects" } + : { type: "server", server: current.server, tab: "projects" } + navigate("/settings", { replace: true, state: { settings: { route: source(), view: parent } } }) + }, close() { if (open()) command.trigger("common.goBack") }, diff --git a/packages/app/src/settings/workspaces/project-dialog.css b/packages/app/src/settings/workspaces/project-dialog.css deleted file mode 100644 index d83fb06f947e..000000000000 --- a/packages/app/src/settings/workspaces/project-dialog.css +++ /dev/null @@ -1,238 +0,0 @@ -.project-settings-dialog [data-slot="dialog-container"] { - background: var(--v2-background-bg-base); -} - -.project-settings-dialog [data-slot="dialog-body"] { - padding: 0; - overflow: hidden; -} - -.project-settings-v2 { - height: 100%; -} - -.project-settings-nav { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; -} - -.project-settings-panel { - display: flex; - flex-direction: column; - height: 100%; - min-width: 0; - overflow: hidden !important; - user-select: none; -} - -.project-settings-panel :is(input, textarea, [contenteditable="true"]) { - user-select: text; -} - -.project-settings-form { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.project-settings-scroll { - display: flex; - flex: 1; - flex-direction: column; - gap: 24px; - min-height: 0; - padding: 40px; - overflow-y: auto; -} - -.project-settings-page-header { - display: flex; - flex-direction: column; - gap: 6px; -} - -.project-settings-page-header h2 { - color: var(--v2-text-text-base); - font-size: 15px; - font-weight: 640; - line-height: 1; -} - -.project-settings-page-header > span { - color: var(--v2-text-text-muted); - font-size: 11px; - font-weight: 440; - line-height: 1; -} - -.project-settings-extensions { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - padding: 40px; -} - -.project-settings-extension-tabs { - flex: 1; - height: auto; - min-height: 0; - margin-top: 24px; - overflow: visible; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] { - width: auto; - padding-inline: 0 !important; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { - display: none; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] - > [data-slot="tabs-v2-list"] - [data-slot="tabs-v2-trigger-wrapper"] { - border-radius: 6px; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] - > [data-slot="tabs-v2-list"] - [data-slot="tabs-v2-trigger"] { - padding-inline: 8px; -} - -.project-settings-extension-tabs [data-slot="tabs-v2-content"] { - overflow-y: auto; -} - -.project-settings-extension-section { - display: flex; - flex-direction: column; - gap: 16px; - padding-top: 20px; -} - -.project-settings-extension-section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - line-height: var(--line-height-compact); -} - -.project-settings-extension-section-header > :last-child { - color: var(--v2-text-text-faint); - font-size: 11px; - font-weight: 440; -} - -.project-settings-extension-link { - color: var(--v2-text-text-accent) !important; - text-decoration: none; -} - -.project-settings-extension-link:hover { - text-decoration: underline; -} - -.project-settings-extension-card { - padding-inline: 12px; - overflow: hidden; - border: 0.5px solid var(--v2-border-border-base); - border-radius: 8px; - background: var(--v2-background-bg-base); -} - -.project-settings-extension-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 48px; - border-bottom: 0.5px solid var(--v2-border-border-base); -} - -.project-settings-extension-row:last-child { - border-bottom: 0; -} - -.project-settings-extension-row-main { - display: flex; - flex: 1; - align-items: center; - gap: 10px; - min-width: 0; -} - -.project-settings-extension-row-icon { - flex-shrink: 0; - color: var(--v2-icon-icon-muted); -} - -.project-settings-extension-row-name { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - text-overflow: ellipsis; - white-space: nowrap; -} - -.project-settings-extension-row-status { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - color: var(--v2-text-text-muted); - font-size: 11px; -} - -.project-settings-extension-row-status-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--v2-state-fg-warning); -} - -.project-settings-shared { - display: flex; - flex-direction: column; - gap: 12px; -} - -.project-settings-shared-trigger { - display: flex; - align-items: center; - align-self: flex-start; - gap: 6px; - color: var(--v2-text-text-base); - font-size: 11px; - font-weight: 530; -} - -.project-settings-shared-chevron { - width: 12px; - height: 12px; - color: var(--v2-icon-icon-muted); - transition: transform 120ms ease; -} - -.project-settings-shared-chevron.open { - transform: rotate(90deg); -} - -.project-settings-shared-count { - padding: 1px 4px; - border-radius: 3px; - background: var(--v2-background-bg-layer-02); - color: var(--v2-text-text-muted); - font-size: 9px; -} diff --git a/packages/app/src/settings/workspaces/project-dialog.tsx b/packages/app/src/settings/workspaces/project-dialog.tsx deleted file mode 100644 index c500f592a81e..000000000000 --- a/packages/app/src/settings/workspaces/project-dialog.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { Dialog, DialogFooter } from "@opencode/ui/dialog" -import { Field } from "@opencode/ui/field" -import { Icon } from "@opencode/ui/icon" -import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar" -import { Tabs } from "@opencode/ui/tabs" -import { Textarea } from "@opencode/ui/textarea" -import { TextInput } from "@opencode/ui/text-input" -import { For, Show, createSignal, startTransition } from "solid-js" -import { useLanguage } from "@/runtime/i18n/language" -import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout" -import { ServerConnection } from "@/runtime/server/registry" -import { LocationProvider } from "@/workspaces/location" -import { displayName } from "@/shell/layout/helpers" -import { ProjectIcon } from "@/shell/layout/project-icon" -import { createEditProjectModel } from "./project-model" -import { ProjectSettingsExtensions } from "./project-extensions" -import { SettingsServerDataScope } from "@/settings/server-scope" -import "@/settings/settings.css" -import "./project-dialog.css" - -export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { - return ( - - - - - - ) -} - -function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) { - const language = useLanguage() - const model = createEditProjectModel(props) - const projectName = () => displayName(props.project) - const [tab, setTab] = createSignal("general") - - const Footer = () => ( - - - - - ) - - return ( - - void startTransition(() => setTab(value))} - class="project-settings-v2" - > - -
- - - {projectName()} - - - - {language.t("project.settings.scripts")} - - - - {language.t("settings.tab.extensions")} - -
-
- - -
-
-
-

{language.t("dialog.project.edit.title")}

- {language.t("project.settings.general.description")} -
- - - {language.t("dialog.project.edit.name")} - model.setStore("name", event.currentTarget.value)} - /> - - -
-
- {language.t("dialog.project.edit.icon")} -
-
- - model.setIconInput(element)} - type="file" - accept="image/*" - class="hidden" - onChange={model.inputChange} - /> -
- {language.t("dialog.project.edit.icon.hint")} - {language.t("dialog.project.edit.icon.recommended")} -
-
-
- - -
-
- {language.t("dialog.project.edit.color")} -
-
- - {(color) => ( - - )} - -
-
-
-
-