Compare commits

...
Author SHA1 Message Date
usrnk1 cb1d095137 refactor(app): group experimental and about settings 2026-09-09 14:23:13 +00:00
usrnk1 83dd84591e fix(desktop): use project name in settings navigation 2026-09-09 10:57:18 +02:00
usrnk1 12ab6d5ade refactor(app): simplify settings navigation and autosave 2026-09-08 17:36:28 +02:00
usrnk1 3244e0613c fix(app): polish project hints and icon preview 2026-09-08 17:23:34 +02:00
usrnk1 356a070afc refactor(app): standardize settings catalog list spacing 2026-09-08 17:23:00 +02:00
usrnk1 739f58aadf feat(app): show configured language servers in project settings 2026-09-08 17:02:10 +02:00
usrnk1 2e57ac20ff 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.
2026-09-08 17:00:48 +02:00
usrnk1 cef086e5db feat(server): expose global preferences and shell discovery 2026-09-08 16:59:17 +02:00
usrnk1 223e9a8c41 refactor(ui): refresh settings navigation icons 2026-09-08 12:07:04 +02:00
usrnk1 23b87714e3 refactor(app): unify settings layout spacing 2026-09-08 12:06:43 +02:00
usrnk1 6e87e858e8 refactor(app): reorganize desktop settings 2026-09-08 12:06:17 +02:00
65 changed files with 3009 additions and 1426 deletions
@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test"
import type { ConfigEntry } from "@opencode/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "/repo/configured-lsp"
const entries: ConfigEntry[] = [
{
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
},
},
},
{
type: "document",
path: `${directory}/opencode.jsonc`,
info: {
lsp: {
typescript: { disabled: true },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]
test.use({ viewport: { width: 1280, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_configured_lsp",
canonical: directory,
name: "Configured LSP project",
sandboxes: [],
time: { created: 1, updated: 1 },
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
}, directory)
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
})
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
const ready = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/config",
async (route) => {
await ready.promise
await route.fulfill({ json: entries })
},
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
ready.resolve()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
await expect(typescript).toContainText("Disabled in config")
await expect(typescript).toContainText(".ts, .tsx")
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
"Enabled in config",
)
await expect(panel.getByRole("switch")).toHaveCount(0)
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
await expect
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
})
for (const lsp of [true, false]) {
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
json: [{ type: "document", info: { lsp } }],
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
).toBeVisible()
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
})
}
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
const state = { fail: true }
await page.route(
(url) => url.pathname === "/api/config",
(route) =>
route.fulfill({
status: state.fail ? 404 : 200,
json: state.fail ? {} : entries,
}),
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
state.fail = false
await panel.getByRole("button", { name: "Retry", exact: true }).click()
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
})
@@ -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<string, number>
// 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" } })
@@ -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()
@@ -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()
@@ -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,131 @@ 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: "Settings demo", 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" })
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
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()
@@ -50,12 +50,12 @@ for (const viewport of [
"Appearance",
"Notifications",
"Shortcuts",
"Servers",
"Projects",
"Worktrees",
"Providers",
"Models",
"Extensions",
"Server",
"Experimental",
"About",
]) {
@@ -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()
})
}
+16
View File
@@ -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,
+22 -1
View File
@@ -9,6 +9,9 @@ export interface MockServerConfig {
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
preferences?: Record<string, unknown>
shells?: unknown[]
websearchProviders?: unknown[]
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -179,13 +182,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<string, string>; 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 +266,29 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
},
projectUpdate: (ctx) => {
const project = config.project as { canonical?: string }
return Effect.succeed({
...project,
...ctx.payload,
id: ctx.params.projectID,
canonical: project.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 },
@@ -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<ServerConnection.Any[]>(() => [
{ 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 (
<Show when={wsl.data}>
<AppInterface
servers={servers()}
defaultServer={ServerConnection.Key.make("sidecar")}
router={(props) => <MemoryRouter {...props} history={history} />}
/>
</Show>
)
}
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale="en">
<output aria-label="WSL actions">{store.calls.join(",")}</output>
<Interface />
</AppBaseProviders>
</PlatformProvider>
)
}, root)
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module">
import { mount } from "./settings-wsl.fixture.tsx"
const query = new URLSearchParams(location.search)
mount({ server: query.get("server"), mode: query.get("mode") })
</script>
</body>
</html>
+1
View File
@@ -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,
},
@@ -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,9 @@ export function createHomeProjectsController(home: HomeController) {
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
settings.openProject({
server: ServerConnection.key(conn),
project: project.worktree,
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+12 -2
View File
@@ -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,
+2 -2
View File
@@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => {
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
</Switch>
</div>
<Show when={expanded()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
@@ -162,12 +162,12 @@ const ModelList: Component<{
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => (
<button
+29
View File
@@ -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 servers 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,14 +1005,36 @@ 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.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $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.lsp.configured": "Configured language servers",
"project.settings.extensions.lsp.status.enabled": "Enabled in config",
"project.settings.extensions.lsp.status.disabled": "Disabled in config",
"project.settings.extensions.lsp.empty.title": "No language servers configured",
"project.settings.extensions.lsp.empty.description": "Language servers configured for this project will appear here",
"project.settings.extensions.lsp.disabled.title": "Language servers disabled",
"project.settings.extensions.lsp.disabled.description": "LSP is disabled in this projects configuration",
"project.settings.extensions.lsp.loadFailed": "Could not load language server configuration",
"project.settings.extensions.lsp.retry": "Retry",
"project.settings.extensions.setupRequired": "Setup required",
"settings.general.section.appearance": "Appearance",
@@ -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<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => 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)
+8 -3
View File
@@ -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)
},
}))
@@ -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])
})
})
+30 -22
View File
@@ -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<string, ServerHealth | undefined>
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<typeof useServerActionsControll
export function useServerCollectionController() {
const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => 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: {
@@ -43,7 +43,7 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
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<{
</Show>
<Show when={props.canRemove}>
<Menu.Separator />
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
</Show>
</Menu.Group>
</Menu.Content>
+38 -9
View File
@@ -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 }) {
<Show
when={platform.wslServers}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
<Show
when={props.compact}
fallback={
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
{language.t("dialog.server.add.button")}
</Button>
}
>
<IconButton
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
onClick={props.onAddServer}
/>
</Show>
}
>
<Menu gutter={4} modal={false} placement="bottom-end">
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
<Show
when={props.compact}
fallback={
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
{language.t("dialog.server.add.button")}
</Menu.Trigger>
}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size="small"
icon={<Icon name="plus" />}
aria-label={language.t("dialog.server.add.button")}
/>
</Show>
<Menu.Portal>
<Menu.Content>
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
@@ -66,7 +93,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
export function WslServerSettings(props: {
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers>
servers: Accessor<readonly WslServerItem[]>
}) {
const platform = usePlatform()
const language = useLanguage()
@@ -154,7 +181,9 @@ export function WslServerSettings(props: {
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
{language.t("dialog.server.menu.remove")}
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu.Portal>
@@ -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 } 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"
@@ -42,9 +43,9 @@ export function SessionProjectMenu(props: {
}) {
const server = useServer()
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 +65,13 @@ 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(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
project: current.worktree,
})
}
return (
@@ -191,7 +194,7 @@ export function SessionProjectMenu(props: {
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
+2 -1
View File
@@ -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)
}
@@ -1,4 +1,5 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ConfigPreferences, ConfigUpdatePreferencesInput } from "@opencode/client/promise"
import type { ColorScheme } from "@opencode/ui/theme/context"
import { useTheme } from "@opencode/ui/theme/context"
import {
@@ -14,32 +15,100 @@ 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<ServerConnection.Any | undefined>) {
export function createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
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 = () => ServerConnection.key(server())
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
source,
() =>
serverCtx()
.sdk.api.config.preferences()
.catch(() => ({})),
{ initialValue: {} },
)
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
const [shells] = createResource(
source,
() =>
serverCtx()
.sdk.api.config.shells()
.catch(() => []),
{ initialValue: [] },
)
const [providers] = createResource(
source,
() =>
serverCtx()
.sdk.api.websearch.providers()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
const update = async (patch: ConfigUpdatePreferencesInput) => {
const context = serverCtx()
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 +208,6 @@ export function createSoundSettingsController() {
}
}
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
+14 -31
View File
@@ -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,10 +323,20 @@ export const SettingsGeneral: Component<{
<WorkspaceDestinationSetting />
<AutoApprovePermissionsSetting />
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<Show when={desktop()}>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("session.review.wrapLines")}
description={language.t("settings.general.row.mobileDiffWrap.description")}
@@ -482,26 +487,6 @@ export const SettingsGeneral: Component<{
</div>
)
// We can probably remove this, right?
const DisplaySection = () => (
<Show when={desktop()}>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
)
return (
<>
<div class="settings-tab-header">
@@ -514,7 +499,7 @@ export const SettingsGeneral: Component<{
</div>
</div>
</div>
<div class="settings-tab-body">
<div class="settings-tab-body settings-tab-body--sectioned">
<GeneralSection />
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
@@ -533,8 +518,6 @@ export const SettingsGeneral: Component<{
<UpdatesSection />
</Show>
<DisplaySection />
<AdvancedSection />
</div>
</>
@@ -417,7 +417,7 @@ function SettingsKeybindsView(props: {
</div>
</div>
<div class="settings-tab-body">
<div class="settings-shortcuts flex flex-col gap-8">
<div class="settings-shortcuts settings-section-stack">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
+6 -2
View File
@@ -1,6 +1,10 @@
import type { Component, JSX } from "solid-js"
import "@/settings/settings.css"
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
return <div data-component="settings-list">{props.children}</div>
export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => {
return (
<div data-component="settings-list" data-variant={props.variant}>
{props.children}
</div>
)
}
+2 -4
View File
@@ -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 = () => {
<h2 class="settings-tab-title">{language.t("settings.models.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
</div>
<InlineServerSelect />
</div>
<div class="settings-tab-search">
<TextInput
@@ -160,12 +158,12 @@ export const SettingsModels: Component = () => {
height={PROVIDER_ICON_SIZE}
class="settings-models-provider-icon shrink-0"
/>
<span class="settings-section-title">{group.items[0].provider.name}</span>
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsList>
<SettingsList variant="catalog">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
+104
View File
@@ -0,0 +1,104 @@
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
export type SettingsNavItem = {
value: string
label: string
icon: ComponentProps<typeof Icon>["name"]
disabled?: boolean
}
export type SettingsNavGroup = {
label?: string
action?: JSX.Element
items: readonly SettingsNavItem[]
}
export function SettingsNavigation(props: {
value: string
groups: readonly SettingsNavGroup[]
backLabel: string
onBack: () => void
onChange: (value: string) => void
mobileAction?: JSX.Element
children: JSX.Element
}) {
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
return (
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={props.onChange} class="settings">
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-mobile-actions">
{props.mobileAction}
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>{current()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup value={props.value} onChange={props.onChange}>
<For each={props.groups}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group.items}>
{(item) => (
<Menu.RadioItem value={item.value} disabled={item.disabled} closeOnSelect>
<Icon name={item.icon} />
{item.label}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={props.onBack}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{props.backLabel}</span>
</button>
<div class="settings-nav-groups">
<For each={props.groups}>
{(group) => (
<div class="settings-nav-group">
<Show when={group.label || group.action}>
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
<span>{group.label}</span>
{group.action}
</div>
</Show>
<For each={group.items}>
{(item) => (
<Tabs.Trigger value={item.value} disabled={item.disabled}>
<Icon name={item.icon} />
{item.label}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
{props.children}
</Tabs>
)
}
@@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
</div>
</div>
<div class="settings-tab-body">
<div class="settings-tab-body settings-tab-body--sectioned">
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
<SettingsList>
@@ -8,7 +8,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useMcpToggle } from "@/providers/connect/mcp"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { InlineServerSelect } from "@/settings/server-select"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
interface McpRowItem {
@@ -63,12 +63,11 @@ export const SettingsExtensions: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs">
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs settings-subtabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
@@ -78,18 +77,20 @@ export const SettingsExtensions: Component = () => {
<Tabs.Content value="mcps">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
<span class="settings-extension-name truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
@@ -97,58 +98,57 @@ export const SettingsExtensions: Component = () => {
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
<span class="text-13-regular text-v2-text-text-muted">
{language.t("settings.extensions.manageConfig")}
</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
<span class="settings-extension-name truncate">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="settings-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
<span class="settings-extension-heading text-13-medium">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
<ExternalLink class="settings-extension-link text-13-regular" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<SettingsList variant="catalog">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<div class="settings-extension-row">
<div class="settings-extension-lead">
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
<span class="settings-extension-name truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</SettingsList>
</div>
</Tabs.Content>
</Tabs>
@@ -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(() => (
<SettingsServerScope directory={props.directory}>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
@@ -134,14 +128,13 @@ export const SettingsProviders: Component<{
<h2 class="settings-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-providers">
<div class="settings-tab-body settings-tab-body--sectioned settings-providers">
<div class="settings-section" data-component="connected-providers-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
<SettingsList>
<SettingsList variant="catalog">
<Show
when={connected().length > 0}
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
@@ -182,7 +175,7 @@ export const SettingsProviders: Component<{
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.providers.section.popular")}</h3>
<SettingsList>
<SettingsList variant="catalog">
<For each={popular()}>
{(item) => (
<div class="settings-provider-row">
+1 -15
View File
@@ -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 (
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
</SettingsServerDataScope>
)}
</Show>
)
}
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
return (
<ServerProvider conn={props.server}>
@@ -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<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
return (
<Show when={options().length > 1}>
<Select
data-action="settings-server-select"
options={options()}
current={current()}
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
label={(server) =>
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
}
optionDisabled={(server) =>
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
}
placement="bottom-end"
gutter={6}
onSelect={(server) => {
if (!server) return
if (server.type === "all") {
props.all?.onSelect()
return
}
global.settings.server.set(ServerConnection.key(server))
props.onServerSelect?.()
}}
/>
</Show>
)
}
@@ -0,0 +1,43 @@
import { createMemo } from "solid-js"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { useWslServers } from "@/servers/wsl/context"
import type { WslServerItem } from "@/servers/wsl/types"
export type SettingsServer = {
key: ServerConnection.Key
name: string
connection?: ServerConnection.Any
wsl?: WslServerItem
}
export function settingsServers(connections: readonly ServerConnection.Any[], wsl: readonly WslServerItem[]) {
const configured = new Map(wsl.map((item) => [item.config.id, item]))
const connected = new Set(connections.map(ServerConnection.key))
return [
...connections.map((connection): SettingsServer => {
const key = ServerConnection.key(connection)
const item = configured.get(key)
return {
key,
name: item?.config.distro ?? (serverName(connection) || key),
connection: item && item.runtime.kind !== "ready" ? undefined : connection,
wsl: item,
}
}),
...wsl
.filter((item) => !connected.has(ServerConnection.Key.make(item.config.id)))
.map(
(item): SettingsServer => ({
key: ServerConnection.Key.make(item.config.id),
name: item.config.distro,
wsl: item,
}),
),
]
}
export function useSettingsServers() {
const servers = useServers()
const wsl = useWslServers()
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? []))
}
+96 -102
View File
@@ -1,135 +1,129 @@
import { Badge } from "@opencode/ui/badge"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { Select } from "@opencode/ui/select"
import { useDialog } from "@opencode/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createMemo, Show, type Component } from "solid-js"
import { ServerRowMenu } from "@/servers/registry/row-menu"
import { ServerHealthIndicator } from "@/servers/registry/row"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useServerCollectionController } from "@/servers/registry/controller"
import { DialogServer } from "@/servers/connect/dialog"
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
import { SettingsList } from "@/settings/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
import { SettingsRow } from "@/settings/row"
import { ShellSetting } from "@/settings/general/general"
import { createServerPreferencesController } from "@/settings/general/controllers"
import type { SettingsServer } from "./inventory"
import "@/settings/settings.css"
export const SettingsServers: Component = () => {
const WebSearchSetting: Component<{
controller: ReturnType<typeof createServerPreferencesController>["websearch"]
}> = (props) => {
const language = useLanguage()
return (
<SettingsRow
title={language.t("settings.server.preferences.websearch.title")}
description={language.t("settings.server.preferences.websearch.description")}
>
<Select
options={props.controller.options()}
current={props.controller.current()}
value={(option) => String(option.value)}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRow>
)
}
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 [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
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(() => <DialogServer mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
void dialog.push(() => <DialogServer mode="edit" server={server} />)
}
const health = createMemo(() => controller.collection.health()[props.entry.key])
const edit = (server: ServerConnection.Http) =>
void dialog.push(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
return (
<>
<div
class="settings-tab-header settings-servers-header"
classList={{ "settings-tab-header--stacked": showSearch() }}
>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("status.popover.tab.servers")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
<h2 class="settings-tab-title">
{props.nested ? props.entry.name : language.t("settings.section.server")}
</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t(props.nested ? "settings.server.description" : "settings.servers.description")}
</span>
</div>
<AddServerMenu onAddServer={openAdd} />
<Show when={!props.nested && props.onAddServer}>
<AddServerMenu onAddServer={() => props.onAddServer?.()} />
</Show>
</div>
<Show when={showSearch()}>
<div class="settings-tab-search">
<TextInput
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</Show>
</div>
<div class="settings-tab-body settings-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
fallback={
<div class="settings-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
<SettingsList>
<WslServerSettings domain={controller} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.collection.health()[key]
const isDefault = () => controller.defaults.key() === key
return (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<span class="settings-servers-name">{serverName(item)}</span>
<Show when={health()?.version}>
<span class="settings-servers-meta">v{health()?.version}</span>
<Show
when={props.entry.wsl}
fallback={
<Show when={props.entry.connection}>
{(server) => (
<div class="settings-servers-row">
<div class="settings-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-servers-copy">
<bdi class="settings-servers-name" dir="auto">
{serverName(server()) || props.entry.key}
</bdi>
<bdi class="settings-servers-meta" dir="ltr">
{server().http.url}
</bdi>
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && controller.defaults.key() === props.entry.key}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
</div>
</div>
<div class="settings-servers-actions">
<Show when={controller.defaults.available() && isDefault()}>
<Badge>{language.t("dialog.server.status.default")}</Badge>
</Show>
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
)}
</Show>
}
>
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
</Show>
</SettingsList>
</section>
<Show when={props.entry.connection} keyed>
{(server) => <ServerPreferences server={server} />}
</Show>
</div>
</>
)
}
function ServerPreferences(props: { server: ServerConnection.Any }) {
const language = useLanguage()
const preferences = createServerPreferencesController(() => props.server)
return (
<section class="settings-section">
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
<SettingsList>
<ShellSetting controller={preferences.shell} />
<WebSearchSetting controller={preferences.websearch} />
</SettingsList>
</section>
)
}
+173 -130
View File
@@ -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 {
@@ -70,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;
}
@@ -78,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;
@@ -249,8 +305,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 +337,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,16 +363,18 @@
}
.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-bottom: 0;
.settings-section-stack {
display: flex;
width: 100%;
flex-direction: column;
gap: 32px;
}
[data-component="settings-list"] {
@@ -312,6 +384,12 @@
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
}
[data-component="settings-list"][data-variant="catalog"] {
--settings-list-row-padding: 16px;
--settings-list-icon-gap: 8px;
padding-inline: 16px;
}
.settings-interface-feature [data-component="settings-list"] {
background-color: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
@@ -322,7 +400,7 @@
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -375,6 +453,12 @@
padding: 4px;
}
[data-component="settings-list"][data-variant="catalog"]
[data-slot="settings-row-control"]
> :is(div:has([data-component="switch"]), [data-component="switch"]) {
padding: 0;
}
@media (min-width: 640px) {
[data-slot="settings-row-control"] {
width: auto;
@@ -465,8 +549,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);
}
@@ -505,7 +588,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
@@ -534,7 +617,7 @@
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
gap: var(--settings-list-icon-gap, 10px);
}
.settings-provider-lead:not(:has(.settings-provider-copy)) {
@@ -560,12 +643,12 @@
.settings-provider-name {
font-size: 13px;
font-weight: 530;
line-height: 16px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
.settings-provider-description {
margin-block: -3.5px;
margin: 0;
font-size: 13px;
font-weight: 440;
line-height: 20px;
@@ -573,7 +656,7 @@
}
.settings-provider-empty {
padding-block: 20px;
padding-block: var(--settings-list-row-padding, 20px);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
@@ -612,30 +695,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,25 +791,16 @@
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"] {
color: var(--v2-icon-icon-base);
}
.settings-models [data-component="settings-list"] {
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 +846,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;
@@ -891,34 +934,6 @@
color: var(--v2-text-text-base);
}
.settings-tab-body.settings-servers {
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;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-servers-row {
display: flex;
align-items: center;
@@ -926,6 +941,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;
@@ -970,34 +997,8 @@
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-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 +1007,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 +1250,7 @@
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
padding: 24px 20px 0;
}
.settings-tab-body.settings-workspaces {
@@ -1371,20 +1366,68 @@
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
padding-inline: 0 !important;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
.settings-subtabs > [data-slot="tabs-v2-content"] {
padding-top: 24px;
}
.settings-extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: var(--settings-list-row-padding, 16px);
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-extension-row:last-child {
border-bottom: 0;
}
.settings-extension-lead {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: var(--settings-list-icon-gap, 8px);
}
.settings-extension-name {
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.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-subtabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
display: none;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger-wrapper"] {
border-radius: 6px;
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
> [data-slot="tabs-v2-list"]
[data-slot="tabs-v2-trigger"] {
padding-inline: 8px;
+389 -205
View File
@@ -1,10 +1,18 @@
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, type Accessor } 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 { displayName } from "@/shell/layout/helpers"
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,93 +20,100 @@ 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: "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: "experimental", icon: "flask", label: "settings.tab.experimental" }],
[{ value: "about", icon: "info", label: "settings.tab.about" }],
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
export const SettingsScreen: Component = () => {
const language = useLanguage()
const dialog = useDialog()
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
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 })
})
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 serverCtx = useServerCtx(server)
onMount(() => root?.focus({ preventScroll: true }))
createEffect(() => {
const current = server()
if (current) global.settings.server.set(ServerConnection.key(current))
const next = surface.view().type
if (next === viewType) return
viewType = next
queueMicrotask(() => root?.focus({ preventScroll: true }))
})
const directory = createMemo(() => {
const selected = global.settings.server.selected()
const current = server()
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
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
return undefined
})
const showProviders = () => {
dialog.close()
surface.open("providers")
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)
})
createEffect(() => {
const view = surface.view()
if (view.type !== "server" || servers().length !== 1) return
surface.open(view.tab === "general" ? "servers" : view.tab)
})
return (
<div
@@ -109,138 +124,307 @@ export const SettingsScreen: Component = () => {
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
surface.close()
surface.back()
}}
>
<Tabs
orientation="vertical"
variant="settings"
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
class="settings"
>
<div class="settings-mobile-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<Menu placement="bottom-end" gutter={8}>
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
<span>
{language.t(
sections.flat().find((section) => section.value === surface.tab())?.label ??
"settings.tab.preferences",
)}
</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.RadioGroup
value={surface.tab()}
onChange={(value) => void startTransition(() => surface.open(value))}
>
<For each={sections}>
{(group, index) => (
<>
<Show when={index() > 0}>
<Menu.Separator />
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
)}
</For>
</>
)}
</For>
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
<Tabs.List>
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
<For each={sections}>
{(group) => (
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
)}
</For>
</div>
)}
</For>
</div>
</div>
</Tabs.List>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral server={server()} />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="servers" class="settings-panel">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects />
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerScope>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.tab() === "about"} />
</Tabs.Content>
</Tabs>
<Switch>
<Match when={surface.view().type === "root"}>
<RootSettings />
</Match>
<Match when={surface.view().type === "server"}>
<Show when={targetServer()}>{(server) => <ServerSettings entry={server()} />}</Show>
</Match>
<Match when={surface.view().type === "project"}>
<Show when={targetServer()?.connection} keyed>
{(server) => (
<Show when={targetProject()}>{(project) => <ProjectSettings server={server} project={project()} />}</Show>
)}
</Show>
</Match>
</Switch>
</div>
)
}
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 sourceDirectory = useSettingsDirectory(sourceServer)
const addServer = () =>
void dialog.push(() => (
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...(multiple()
? [
{
label: language.t("status.popover.tab.servers"),
action: <AddServerMenu compact onAddServer={addServer} />,
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") },
],
},
]),
{ items: trailingTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
])
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 (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToApp")}
onBack={() => surface.close()}
onChange={change}
mobileAction={multiple() ? <AddServerMenu compact onAddServer={addServer} /> : undefined}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="appearance" class="settings-panel">
<SettingsAppearance />
</Tabs.Content>
<Tabs.Content value="notifications" class="settings-panel">
<SettingsNotifications />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
<Tabs.Content value="about" class="settings-panel settings-about">
<SettingsAbout active={surface.view().tab === "about"} />
</Tabs.Content>
<Show when={single()} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: ServerConnection.key(server),
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={sourceServer() === server ? sourceDirectory() : undefined}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
<Show when={singleEntry()}>
{(entry) => (
<Tabs.Content value="servers" class="settings-panel">
<SettingsServerGeneral entry={entry()} onAddServer={addServer} />
</Tabs.Content>
)}
</Show>
</SettingsNavigation>
)
}
function ServerSettings(props: { entry: SettingsServer }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const groups = createMemo<SettingsNavGroup[]>(() => [
{
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)
}
return (
<SettingsNavigation
value={surface.view().tab}
groups={groups()}
backLabel={language.t("settings.backToSettings")}
onBack={() => surface.back()}
onChange={change}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsServerGeneral
entry={props.entry}
nested
onServerChange={(server) => surface.replaceServer(ServerConnection.key(server))}
/>
</Tabs.Content>
<Show when={props.entry.connection} keyed>
{(server) => (
<SettingsServerDataScope server={server}>
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects
server={server}
onOpenProject={(project) =>
surface.openProject({
server: props.entry.key,
project: project.worktree,
})
}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={activeDirectory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
</Tabs.Content>
<Tabs.Content value="models" class="settings-panel">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<SettingsExtensions />
</Tabs.Content>
</SettingsServerDataScope>
)}
</Show>
</SettingsNavigation>
)
}
function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) {
const language = useLanguage()
const surface = useSettingsSurface()
const activeDirectory = useSettingsDirectory(() => props.server)
const groups: SettingsNavGroup[] = [
{
items: nestedProjectTabs.map((item) => ({
...item,
get label() {
return item.value === "general" ? displayName(props.project) : language.t(item.label)
},
})),
},
]
return (
<SettingsServerDataScope server={props.server} directory={props.project.worktree}>
<LocationProvider directory={props.project.worktree}>
<SettingsNavigation
value={surface.view().tab}
groups={groups}
backLabel={language.t("settings.backToProjects")}
onBack={() => surface.back()}
onChange={(value) => surface.select(value)}
>
<Tabs.Content value="general" class="settings-panel">
<SettingsProjectGeneral
server={props.server}
project={props.project}
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces projectID={props.project.id} activeDirectory={activeDirectory()} />
</Tabs.Content>
<Tabs.Content value="extensions" class="settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</SettingsNavigation>
</LocationProvider>
</SettingsServerDataScope>
)
}
function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) {
return list.find((item) => ServerConnection.key(item) === key)
}
function useSettingsDirectory(server: Accessor<ServerConnection.Any | undefined>) {
const surface = useSettingsSurface()
const tabs = useTabs()
const serverCtx = useServerCtx(server)
return createMemo(() => {
const current = server()
if (!current) return undefined
const key = ServerConnection.key(current)
const route = surface.route()
if (route.type === "session" && route.server === 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 === key ? draft.directory : undefined
})
}
+124 -10
View File
@@ -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<SettingsRootTab, true> = {
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<SettingsServerTab, true> = {
general: true,
projects: true,
workspaces: true,
providers: true,
models: true,
extensions: true,
}
const projectTabs: Record<SettingsProjectTab, true> = {
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,13 +80,24 @@ 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<LayoutRoute, { type: "settings" }>; tab: string }
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; 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
const show = (view: SettingsView, replace: boolean) => {
const route = layout.route()
if (route.type !== "settings" && document.activeElement instanceof HTMLElement) focus = document.activeElement
navigate("/settings", {
replace,
state: { settings: { route: route.type === "settings" ? source() : route, view } },
})
}
createEffect(
on(
open,
@@ -33,16 +113,50 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
return {
active: open,
route: source,
tab: () => location.state?.settings?.tab ?? "general",
open(tab = "general") {
const route = layout.route()
if (route.type !== "settings") {
if (document.activeElement instanceof HTMLElement) focus = document.activeElement
view,
open(tab: SettingsRootTab = "general") {
show({ type: "root", tab }, open())
},
openServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, false)
},
replaceServer(server: string, tab: SettingsServerTab = "general") {
show({ type: "server", server, tab }, true)
},
openProject(input: { server: string; project: string; tab?: SettingsProjectTab }) {
show(
{
type: "project",
...input,
parent: servers().length > 1 ? "server" : "root",
tab: input.tab ?? "general",
},
false,
)
},
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
show(next, true)
},
back() {
const current = view()
if (current.type === "root") {
command.trigger("common.goBack")
return
}
navigate("/settings", {
replace: open(),
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
})
const parent: SettingsView =
current.type === "server" || current.parent === "root"
? { type: "root", tab: current.type === "server" ? "general" : "projects" }
: { type: "server", server: current.server, tab: "projects" }
show(parent, true)
},
close() {
if (open()) command.trigger("common.goBack")
@@ -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;
}
@@ -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 (
<SettingsServerDataScope server={props.server}>
<LocationProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</LocationProvider>
</SettingsServerDataScope>
)
}
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 = () => (
<DialogFooter>
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
)
return (
<Dialog size="x-large" variant="settings" class="project-settings-dialog">
<Tabs
orientation="vertical"
variant="settings"
value={tab()}
onChange={(value) => void startTransition(() => setTab(value))}
class="project-settings-v2"
>
<Tabs.List>
<div class="project-settings-nav">
<Tabs.Trigger value="general">
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
<span class="truncate">{projectName()}</span>
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
<Icon name="code" size="small" />
{language.t("project.settings.scripts")}
</Tabs.Trigger>
<Tabs.Trigger value="extensions">
<Icon name="extensions" size="small" />
{language.t("settings.tab.extensions")}
</Tabs.Trigger>
</div>
</Tabs.List>
<Tabs.Content value="general" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("dialog.project.edit.title")}</h2>
<span>{language.t("project.settings.general.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
<TextInput
autofocus
appearance="large"
class="!w-full"
value={model.store.name}
placeholder={model.folderName()}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
/>
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
}}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={model.drop}
onDragOver={model.dragOver}
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
classList={{
"opacity-100": model.store.iconHover,
"opacity-0": !model.store.iconHover,
}}
>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={model.inputChange}
/>
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
</div>
</div>
</div>
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
classList={{
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
getProjectAvatarVariant(model.store.color) === color,
}}
onClick={() => {
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
model.setStore(
"color",
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
)
}}
>
<ProjectAvatar
fallback={model.store.name || model.defaultName()}
variant={getProjectAvatarVariant(color)}
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="scripts" class="project-settings-panel">
<form onSubmit={model.submit} class="project-settings-form">
<div class="project-settings-scroll">
<div class="project-settings-page-header">
<h2>{language.t("project.settings.scripts")}</h2>
<span>{language.t("project.settings.scripts.description")}</span>
</div>
<Field>
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
/>
</Field>
</div>
<Footer />
</form>
</Tabs.Content>
<Tabs.Content value="extensions" class="project-settings-panel">
<ProjectSettingsExtensions />
</Tabs.Content>
</Tabs>
</Dialog>
)
}
@@ -1,7 +1,19 @@
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tabs } from "@opencode/ui/tabs"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
import { Button } from "@opencode/ui/button"
import { useQuery } from "@tanstack/solid-query"
import {
type Component,
For,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
type JSX,
} from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -9,6 +21,9 @@ import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { configuredLanguageServers } from "./project-lsp"
import { SettingsList } from "@/settings/list"
import "./project.css"
type SkillItem = {
name: string
@@ -18,23 +33,54 @@ type SkillItem = {
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
<div class="project-settings-extension-card">{props.children}</div>
<SettingsList variant="catalog">{props.children}</SettingsList>
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill"
icon: "mcp" | "cube" | "post-skill" | "code"
name: string
description?: JSX.Element
children?: JSX.Element
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<div class="settings-extension-row project-settings-extension-row">
<div class="settings-extension-lead">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
<div class="project-settings-extension-row-copy">
<span class="project-settings-extension-row-name settings-extension-name">{props.name}</span>
<Show when={props.description}>
<span class="project-settings-extension-row-description">{props.description}</span>
</Show>
</div>
</div>
{props.children}
</div>
)
const ProjectSectionHeader: Component<{
kind: "mcps" | "plugins" | "skills"
empty: boolean
action: JSX.Element
}> = (props) => {
const language = useLanguage()
return (
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{props.empty
? language.t(`project.settings.extensions.empty.${props.kind}.title`)
: language.t("project.settings.extensions.added")}
</span>
<Show when={props.empty}>
<span class="project-settings-extension-empty-description">
{language.t(`project.settings.extensions.empty.${props.kind}.description`)}
</span>
</Show>
</div>
{props.action}
</div>
)
}
const SharedSection: Component<{
count: number
children: JSX.Element
@@ -46,13 +92,21 @@ const SharedSection: Component<{
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
class="settings-models-group-trigger project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
<span class="settings-models-group-chevron">
<Icon
name="fill-triangle-down"
size="small"
classList={{ "project-settings-shared-chevron": true, open: open() }}
/>
</span>
<span class="project-settings-shared-label">
<span class="settings-models-group-title">{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
@@ -62,6 +116,95 @@ const SharedSection: Component<{
)
}
const ProjectLanguageServers: Component = () => {
const language = useLanguage()
const server = useServerSDK()
const location = useWorkspaceLocation()
const config = useQuery(() => ({
queryKey: [server.scope, "settings-project-language-servers", location().directory],
enabled: server.connection.status() === "connected",
queryFn: () => server.api.config.get({ location: { directory: location().directory } }),
}))
const configured = createMemo(() =>
configuredLanguageServers(config.isPending || config.isError ? [] : (config.data ?? [])),
)
const empty = () => !config.isPending && !config.isError && configured().servers.length === 0
onCleanup(
server.event.on("config.updated", (event) => {
if (event.location && event.location.directory !== location().directory) return
void config.refetch()
}),
)
return (
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<div class="project-settings-extension-section-copy">
<span>
{language.t(
empty()
? configured().disabled
? "project.settings.extensions.lsp.disabled.title"
: "project.settings.extensions.lsp.empty.title"
: "project.settings.extensions.lsp.configured",
)}
</span>
<Show when={empty()}>
<span class="project-settings-extension-empty-description">
{language.t(
configured().disabled
? "project.settings.extensions.lsp.disabled.description"
: "project.settings.extensions.lsp.empty.description",
)}
</span>
</Show>
</div>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show
when={!config.isPending}
fallback={<p class="project-settings-extension-empty-description">{language.t("common.loading")}</p>}
>
<Show
when={!config.isError}
fallback={
<div class="project-settings-extension-section-copy" role="status">
<span class="project-settings-extension-empty-description">
{language.t("project.settings.extensions.lsp.loadFailed")}
</span>
<Button variant="ghost-muted" onClick={() => void config.refetch()}>
{language.t("project.settings.extensions.lsp.retry")}
</Button>
</div>
}
>
<Show when={configured().servers.length > 0}>
<ExtensionCard>
<For each={configured().servers}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name}
description={item.extensions.length ? <bdi dir="ltr">{item.extensions.join(", ")}</bdi> : undefined}
>
<span class="project-settings-extension-row-status">
{language.t(
item.disabled
? "project.settings.extensions.lsp.status.disabled"
: "project.settings.extensions.lsp.status.enabled",
)}
</span>
</ExtensionRow>
)}
</For>
</ExtensionCard>
</Show>
</Show>
</Show>
</div>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -145,61 +288,77 @@ export const ProjectSettingsExtensions: Component = () => {
)
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("project.settings.extensions.description")}
</span>
</div>
</div>
</div>
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
</Tabs.List>
<div class="settings-tab-body">
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs settings-subtabs">
<Tabs.List>
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
<Tabs.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
<Tabs.Content value="mcps">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="mcps"
empty={projectMcpNames().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
<Tabs.Content value="plugins">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="plugins"
empty={projectPlugins().length === 0}
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
/>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</Tabs.Content>
</Tabs.Content>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
<Tabs.Content value="skills">
<div class="project-settings-extension-section">
<ProjectSectionHeader
kind="skills"
empty={projectSkills().length === 0}
action={
<ExternalLink class="settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
}
/>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</Tabs.Content>
</Tabs>
</div>
</Tabs.Content>
<Tabs.Content value="lsps">
<ProjectLanguageServers />
</Tabs.Content>
</Tabs>
</div>
</>
)
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import type { ConfigEntry } from "@opencode/client/promise"
import { configuredLanguageServers } from "./project-lsp"
const global: ConfigEntry = {
type: "document",
path: "/config/opencode.json",
info: {
lsp: {
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
eslint: { disabled: true },
},
},
}
describe("configuredLanguageServers", () => {
test("merges inherited entries and project overrides by name", () => {
expect(
configuredLanguageServers([
global,
{ type: "directory", path: "/project/.opencode" },
{
type: "document",
path: "/project/opencode.jsonc",
info: {
lsp: {
typescript: { disabled: true },
eslint: { command: ["eslint-lsp"], disabled: false, extensions: [".js"] },
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
},
},
},
]),
).toEqual({
disabled: false,
servers: [
{ name: "eslint", disabled: false, extensions: [".js"] },
{ name: "rust", disabled: false, extensions: [".rs"] },
{ name: "typescript", disabled: true, extensions: [".ts", ".tsx"] },
],
})
})
test("does not invent servers for omitted or boolean-only configuration", () => {
expect(configuredLanguageServers([])).toEqual({ disabled: false, servers: [] })
expect(configuredLanguageServers([global, { type: "document", info: { lsp: false } }])).toEqual({
disabled: true,
servers: [],
})
expect(configuredLanguageServers([global, { type: "document", info: { lsp: true } }])).toEqual({
disabled: false,
servers: [],
})
})
test("allows named configuration after a disabled parent without reviving earlier entries", () => {
expect(
configuredLanguageServers([
global,
{ type: "document", info: { lsp: false } },
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
]),
).toEqual({ disabled: false, servers: [{ name: "custom", disabled: false, extensions: [] }] })
})
})
@@ -0,0 +1,28 @@
import type { ConfigEntry } from "@opencode/client/promise"
type ConfiguredServer = { name: string; disabled: boolean; extensions: readonly string[] }
export function configuredLanguageServers(entries: readonly ConfigEntry[]) {
const state = entries.reduce(
(state, entry) => {
if (entry.type !== "document" || entry.info.lsp === undefined) return state
const config = entry.info.lsp
// A boolean replaces the object form, so earlier named entries no longer apply.
if (typeof config === "boolean") return { disabled: !config, servers: new Map<string, ConfiguredServer>() }
Object.entries(config).forEach(([name, server]) => {
const previous = state.servers.get(name)
state.servers.set(name, {
name,
disabled: server.disabled ?? previous?.disabled ?? false,
extensions: ("extensions" in server ? server.extensions : undefined) ?? previous?.extensions ?? [],
})
})
return { disabled: false, servers: state.servers }
},
{ disabled: false, servers: new Map<string, ConfiguredServer>() },
)
return {
disabled: state.disabled,
servers: [...state.servers.values()].sort((a, b) => a.name.localeCompare(b.name)),
}
}
@@ -1,14 +1,17 @@
import { getFilename } from "@opencode/util/path"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import type { ProjectUpdateInput } from "@opencode/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
import { type LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import type { LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { showToast } from "@/shell/notifications/toast"
type ProjectPatch = Pick<ProjectUpdateInput, "name" | "icon" | "commands">
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const language = useLanguage()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -20,8 +23,62 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
saving: 0,
})
const saved = {
name: props.project.name ?? "",
startup: store.startup.trim(),
color: store.color,
iconOverride: store.iconOverride,
}
let iconInput: HTMLInputElement | undefined
let queue = Promise.resolve()
const persist = (patch: ProjectPatch, complete: () => void) => {
setStore("saving", (value) => value + 1)
queue = queue
.then(async () => {
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, ...patch })
serverCtx().sync.project.update(project)
return
}
serverCtx().sync.project.meta(props.project.worktree, patch)
})
.then(complete)
.catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
})
.finally(() => setStore("saving", (value) => value - 1))
}
const saveName = () => {
const value = store.name.trim() === folderName() ? "" : store.name.trim()
if (value === saved.name) return
persist({ name: value }, () => {
saved.name = value
})
}
const saveStartup = () => {
const value = store.startup.trim()
if (value === saved.startup) return
persist({ commands: { start: value } }, () => {
saved.startup = value
})
}
const saveIcon = (color = store.color, override = store.iconOverride) => {
if (color === saved.color && override === saved.iconOverride) return
persist({ icon: { color: color ?? "", override: override ?? "" } }, () => {
saved.color = color
saved.iconOverride = override
})
}
function selectFile(file: File) {
if (!file.type.startsWith("image/")) return
@@ -31,85 +88,46 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
if (typeof result !== "string") return
setStore("iconOverride", result)
setStore("iconHover", false)
saveIcon(store.color, result)
}
reader.readAsDataURL(file)
}
function drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
}
function dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
}
function dragLeave() {
setStore("dragOver", false)
}
function inputChange(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]
if (file) selectFile(file)
}
function iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
return
}
iconInput?.click()
}
const save = useMutation(() => ({
mutationFn: async () => {
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
serverCtx().sync.project.update(project)
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
},
}))
function submit(event: SubmitEvent) {
event.preventDefault()
if (save.isPending) return
save.mutate()
}
return {
store,
setStore,
folderName,
defaultName,
save,
submit,
drop,
dragOver,
dragLeave,
inputChange,
iconClick,
close() {
dialog.close()
saveName,
saveStartup,
setColor(value: string | undefined) {
setStore("color", value)
saveIcon(value, store.iconOverride)
},
drop(event: DragEvent) {
event.preventDefault()
setStore("dragOver", false)
const file = event.dataTransfer?.files[0]
if (file) selectFile(file)
},
dragOver(event: DragEvent) {
event.preventDefault()
setStore("dragOver", true)
},
dragLeave() {
setStore("dragOver", false)
},
inputChange(input: HTMLInputElement) {
const file = input.files?.[0]
if (file) selectFile(file)
},
iconClick() {
if (store.iconOverride && store.iconHover) {
setStore("iconOverride", "")
saveIcon(store.color, "")
return
}
iconInput?.click()
},
setIconInput(input: HTMLInputElement) {
iconInput = input
@@ -0,0 +1,299 @@
.project-settings-extension-tabs {
flex: 1;
height: auto;
min-height: 0;
overflow: visible;
}
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: auto;
}
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
overflow-y: auto;
}
.project-settings-extension-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.project-settings-extension-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
color: var(--text-strong);
}
.project-settings-extension-section-header > :last-child {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
}
.project-settings-extension-empty-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-extension-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.project-settings-extension-row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-settings-extension-row-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.project-settings-extension-row-description {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
overflow-wrap: anywhere;
}
.project-settings-extension-row-status {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
line-height: var(--line-height-compact);
}
.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;
margin-top: 16px;
}
.project-settings-shared-trigger {
width: fit-content;
align-self: flex-start;
}
.project-settings-shared-label {
display: flex;
align-items: center;
gap: 6px;
}
.project-settings-shared-chevron {
color: var(--v2-icon-icon-muted);
transition: transform 120ms ease;
transform: rotate(-90deg);
}
.project-settings-shared-chevron.open {
transform: rotate(0deg);
}
[dir="rtl"] .project-settings-shared-chevron {
transform: rotate(90deg);
}
[dir="rtl"] .project-settings-shared-chevron.open {
transform: rotate(0deg);
}
.project-settings-shared-count {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding-inline: 4px;
border-radius: 3px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-name {
width: min(280px, 100%);
}
.project-settings-server-link {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.project-settings-server-link:hover,
.project-settings-server-link:focus-visible {
color: var(--v2-text-text-base);
text-decoration: underline;
}
.project-settings-server-link:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.project-settings-icon {
position: relative;
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
overflow: hidden;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.project-settings-icon--active,
.project-settings-icon:focus-visible {
outline-color: var(--v2-border-border-focus);
}
.project-settings-icon-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: color-mix(in srgb, var(--v2-background-bg-contrast) 80%, transparent);
color: var(--v2-icon-icon-contrast);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.project-settings-icon-overlay.visible {
opacity: 1;
}
.project-settings-colors {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.project-settings-color {
position: relative;
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
outline: none;
background: transparent;
transition: box-shadow 120ms ease;
}
.project-settings-color:focus-visible,
.project-settings-color--selected {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-focus);
}
@media (hover: hover) {
.project-settings-color:not(.project-settings-color--selected):hover {
box-shadow:
0 0 0 2px var(--v2-background-bg-layer-01),
0 0 0 4px var(--v2-border-border-strong);
}
}
.project-settings-color-check {
position: absolute;
color: var(--v2-icon-icon-contrast);
}
.project-settings-startup {
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 20px;
}
.project-settings-startup-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.project-settings-startup-title {
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
}
.project-settings-startup-description,
.project-settings-startup-hint {
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-startup-hint code {
padding-inline: 4px;
border-radius: 4px;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-base);
font-family: var(--font-family-mono);
font-size: 12px;
unicode-bidi: isolate;
}
@media (prefers-reduced-motion: reduce) {
.project-settings-icon-overlay,
.project-settings-color,
.project-settings-shared-chevron {
transition: none;
}
}
@@ -0,0 +1,167 @@
import { Icon } from "@opencode/ui/icon"
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
import { Textarea } from "@opencode/ui/textarea"
import { TextInput } from "@opencode/ui/text-input"
import { For, Show, type Component } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { useSettingsServers } from "@/settings/servers/inventory"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { createEditProjectModel } from "./project-model"
import "./project.css"
export const SettingsProjectGeneral: Component<{
project: LocalProject
server: ServerConnection.Any
onOpenServer: () => void
}> = (props) => {
const language = useLanguage()
const model = createEditProjectModel(props)
const servers = useSettingsServers()
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
</div>
</div>
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
<SettingsList>
<SettingsRow
title={language.t("project.settings.name.title")}
description={language.t("project.settings.name.description")}
>
<div class="project-settings-name">
<TextInput
type="text"
appearance="base"
value={model.store.name}
placeholder={model.folderName()}
aria-label={language.t("project.settings.name.title")}
onInput={(event) => model.setStore("name", event.currentTarget.value)}
onBlur={model.saveName}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("dialog.project.edit.icon")}
description={language.t("project.settings.icon.description")}
>
<button
type="button"
aria-label={language.t("dialog.project.edit.icon.alt")}
class="project-settings-icon"
classList={{ "project-settings-icon--active": model.store.dragOver }}
onMouseEnter={() => model.setStore("iconHover", true)}
onMouseLeave={() => model.setStore("iconHover", false)}
onDrop={(event) => model.drop(event)}
onDragOver={(event) => model.dragOver(event)}
onDragLeave={() => model.dragLeave()}
onClick={() => model.iconClick()}
>
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-8 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
/>
<span classList={{ "project-settings-icon-overlay": true, visible: model.store.iconHover }}>
<Icon name={model.store.iconOverride ? "close" : "share"} />
</span>
</button>
<input
ref={(element) => model.setIconInput(element)}
type="file"
accept="image/*"
class="hidden"
onChange={(event) => model.inputChange(event.currentTarget)}
/>
</SettingsRow>
<Show when={!model.store.iconOverride}>
<SettingsRow
title={language.t("dialog.project.edit.color")}
description={language.t("project.settings.color.description")}
>
<div class="project-settings-colors">
<For each={PROJECT_AVATAR_VARIANTS}>
{(color) => {
const selected = () => getProjectAvatarVariant(model.store.color) === color
return (
<button
type="button"
aria-label={language.t("dialog.project.edit.color.select", { color })}
aria-pressed={selected()}
class="project-settings-color"
classList={{ "project-settings-color--selected": selected() }}
onClick={() => model.setColor(selected() ? undefined : color)}
>
<ProjectAvatar
fallback=""
variant={color}
class="!size-5 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
/>
<Show when={selected()}>
<Icon name="check" size="small" class="project-settings-color-check" />
</Show>
</button>
)
}}
</For>
</div>
</SettingsRow>
</Show>
<div class="project-settings-startup">
<div class="project-settings-startup-copy">
<span class="project-settings-startup-title">{language.t("dialog.project.edit.worktree.startup")}</span>
<span class="project-settings-startup-description">
{language.t("project.settings.worktree.startup.description")}
</span>
</div>
<Textarea
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
rows={5}
value={model.store.startup}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
aria-label={language.t("dialog.project.edit.worktree.startup")}
spellcheck={false}
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
onBlur={model.saveStartup}
/>
<div class="project-settings-startup-hint flex flex-col">
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.base"))}</span>
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.new"))}</span>
</div>
</div>
</SettingsList>
</div>
</>
)
}
function inlineVariables(text: string) {
return text
.split(/(\$[A-Z][A-Z0-9_]*)/g)
.map((part, index) => (index % 2 === 0 ? part : <code dir="ltr">{part}</code>))
}
+32 -116
View File
@@ -1,69 +1,20 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButton } from "@opencode/ui/icon-button"
import { For, Show, createMemo, type Component } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { useDialog } from "@opencode/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { ServerConnection } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { InlineServerSelect } from "@/settings/server-select"
import { DialogEditProject } from "./project-dialog"
import type { LocalProject } from "@/shell/state/layout"
import "@/settings/settings.css"
export const SettingsProjects: Component = () => {
const dialog = useDialog()
export const SettingsProjects: Component<{
server: ServerConnection.Any
onOpenProject: (project: LocalProject) => void
}> = (props) => {
const language = useLanguage()
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const multiple = createMemo(() => global.servers.list().length > 1)
const projects = createMemo(() => {
const server = selected()
if (!server) return []
return global.ensureServerCtx(server).projects.list()
})
type ProjectItem = ReturnType<typeof projects>[number]
const groups = createMemo(() =>
global.servers
.list()
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
.filter((group) => group.projects.length > 0),
)
const openProjectSettings = (project: ProjectItem, server = selected()) => {
if (!server) return
dialog.push(() => <DialogEditProject project={project} server={server} />)
}
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
return (
<div
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<IconButton
type="button"
variant="ghost-muted"
size="small"
icon={<Icon name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
onClick={(event: MouseEvent) => {
event.stopPropagation()
openProjectSettings(props.project, props.server)
}}
/>
</div>
</div>
)
}
const projects = createMemo(() => global.ensureServerCtx(props.server).projects.list())
return (
<>
@@ -73,74 +24,39 @@ export const SettingsProjects: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={multiple()}>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
<Show
when={allServers()}
when={projects().length > 0}
fallback={
<div class="flex flex-col gap-2 w-full">
<Show
when={projects().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<Show when={selected()} keyed>
{(server) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
</div>
</div>
)}
</Show>
</Show>
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="flex flex-col gap-8 w-full">
<Show
when={groups().length > 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<For each={groups()}>
{(group) => (
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
</For>
</div>
</div>
)}
</For>
</Show>
<div class="flex w-full flex-col gap-2">
<For each={projects()}>
{(project) => (
<button
type="button"
aria-label={displayName(project)}
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
onClick={() => props.onOpenProject(project)}
>
<span class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectIcon project={project} class="shrink-0" />
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
</span>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</button>
)}
</For>
</div>
</Show>
</div>
@@ -21,7 +21,6 @@ import { getRelativeTime } from "@/shell/time"
import { sessionLabel } from "@/session/title"
import { pathKey } from "@/workspaces/path-key"
import { SettingsList } from "@/settings/list"
import { InlineServerSelect } from "@/settings/server-select"
import { useTabs } from "@/shell/tabs/tabs"
import { usePlatform } from "@/runtime/platform/platform"
import { clearWorkspaceTerminals } from "@/session/terminal/context"
@@ -48,9 +47,11 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
props,
) => {
export const SettingsWorkspaces: Component<{
activeDirectory?: string
resetProjectFilter?: () => number
projectID?: string
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -64,7 +65,11 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
if (props.projectID) {
setStore("project", props.projectID)
return
}
props.resetProjectFilter?.()
setStore("project", "all")
})
@@ -91,7 +96,11 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
props.projectID
? props.projectID
: store.project === "all" || projects().some((project) => project.id === store.project)
? store.project
: "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
@@ -328,18 +337,17 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
</div>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<span class="settings-section-title">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Show when={!props.projectID && projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<span class="min-w-0 truncate">
+3
View File
@@ -35,6 +35,9 @@ export default defineConfig({
port: 3000,
},
build: {
...(process.env.VITE_OPENCODE_TEST_FIXTURES === "1"
? { rolldownOptions: { input: ["index.html", "e2e/utils/settings-wsl.html"] } }
: {}),
assetsDir: "_assets",
target: "esnext",
sourcemap: true,
+22
View File
@@ -2111,8 +2111,30 @@ export type ConfigGetInput = {
export type ConfigGetOutput = ReadonlyArray<Config.Entry>
export type ConfigGetOperation<E = never> = (input?: ConfigGetInput) => Effect.Effect<ConfigGetOutput, E>
export type ConfigPreferencesOutput = Config.Preferences
export type ConfigPreferencesOperation<E = never> = () => Effect.Effect<ConfigPreferencesOutput, E>
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<E = never> = (
input?: ConfigUpdatePreferencesInput,
) => Effect.Effect<ConfigUpdatePreferencesOutput, E>
export type ConfigShellsOutput = ReadonlyArray<{
readonly path: string
readonly name: string
readonly acceptable: boolean
}>
export type ConfigShellsOperation<E = never> = () => Effect.Effect<ConfigShellsOutput, E>
export interface ConfigApi<E = never> {
readonly get: ConfigGetOperation<E>
readonly preferences: ConfigPreferencesOperation<E>
readonly updatePreferences: ConfigUpdatePreferencesOperation<E>
readonly shells: ConfigShellsOperation<E>
}
export interface AppApi<E = never> {
+23 -1
View File
@@ -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<ConfigPreferencesOutput>()(raw["config.preferences"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointConfigUpdatePreferences = (raw: RawClient["server.config"]) => (input?: ConfigUpdatePreferencesInput) =>
preserveEffect<ConfigUpdatePreferencesOutput>()(
raw["config.updatePreferences"]({ payload: { shell: input?.["shell"], websearch: input?.["websearch"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointConfigShells = (raw: RawClient["server.config"]) => () =>
preserveEffect<ConfigShellsOutput>()(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"]),
@@ -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<ConfigPreferencesOutput>(
{
method: "GET",
path: `/api/config/preferences`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
updatePreferences: (input?: ConfigUpdatePreferencesInput, requestOptions?: RequestOptions) =>
request<ConfigUpdatePreferencesOutput>(
{
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<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
}
}
@@ -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<ConfigEntry>
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<ConfigShellOption>
+61 -1
View File
@@ -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<Watcher.Update>
/** Returns preferences from the highest-precedence global config document. */
readonly preferences?: () => Effect.Effect<Preferences, FSUtil.Error>
/** Patches preferences in the highest-precedence global config document. */
readonly updatePreferences?: (patch: PreferencesPatch) => Effect.Effect<Preferences, FSUtil.Error>
}
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,
})
}),
)
+2
View File
@@ -47,6 +47,7 @@ export type ResolveInput = {
export interface Interface extends State.Transformable<Editor> {
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
readonly list?: () => Effect.Effect<Item[]>
}
export class Service extends Context.Service<Service, Interface>()("@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)),
})
}),
)
+35
View File
@@ -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." }))
+12
View File
@@ -109,6 +109,18 @@ export class Info extends Schema.Class<Info>("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<typeof Preferences> {}
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<typeof PreferencesPatch> {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: AbsolutePath.pipe(optional),
+10
View File
@@ -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<typeof Option> {}
+28 -1
View File
@@ -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()
}),
),
)
+48
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+17 -7
View File
@@ -93,23 +93,33 @@ export const additionalIcons = {
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
sliders: `<path d="M11.7778 4.66797H14.4444M11.7778 4.66797C11.7778 5.77254 10.8823 6.66797 9.77776 6.66797C8.67319 6.66797 7.77776 5.77254 7.77776 4.66797M11.7778 4.66797C11.7778 3.5634 10.8823 2.66797 9.77776 2.66797C8.67319 2.66797 7.77776 3.5634 7.77776 4.66797M1.55554 4.66797H7.77776M4.22221 11.3346H1.55554M4.22221 11.3346C4.22221 12.4392 5.11764 13.3346 6.22221 13.3346C7.32678 13.3346 8.22221 12.4392 8.22221 11.3346M4.22221 11.3346C4.22221 10.2301 5.11764 9.33464 6.22221 9.33464C7.32678 9.33464 8.22221 10.2301 8.22221 11.3346M14.4444 11.3346H8.22221" stroke="currentColor"/>`,
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-down-to-line": `<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
warning: `<path d="M10 7.91667V11.6667M10 13.7417V13.75M10 2.5L1.875 16.25H18.125L10 2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
reset: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
appearance: `<path d="M2.707 14.707L14.707 2.707M2.707 9.06L9.06 2.707M2.707 3.413L3.413 2.707M8.354 14.707L14.707 8.354M14.000 14.706L14.706 14.000" stroke="currentColor" stroke-linecap="square"/>`,
notifications: `<path d="M15.389 7.278V9.611C15.389 10.593 15.389 11.389 15.389 11.389H11.833M6.056 11.389H2.5C2.5 11.389 2.5 10.593 2.5 9.611V4.278C2.5 3.296 2.5 2.5 2.5 2.5H9.945M6.056 11.389V13.611H8.944H11.833V11.389M6.056 11.389H11.833" stroke="currentColor"/><circle cx="14.5" cy="4.5" r="2" stroke="currentColor" fill="currentColor"/>`,
extensions: `<path d="M9.166 6.805V9.305M11.834 6.805V9.305M6.5 6.805V9.305M2.5 2.5V13.61H15.833V2.5H2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
providers: `<path d="M5.11115 3.33252H10.8889M5.11115 3.33252L3.33337 3.33254V12.6658H5.11115M5.11115 3.33252L5.11108 1.55469M10.8889 3.33252L12.6667 3.33252V12.6658H10.8889M10.8889 3.33252L10.8889 1.55469M10.8889 12.6658H5.11115M10.8889 12.6658L10.8889 14.4435M5.11115 12.6658L5.11108 14.4435M8 1.55469V3.33247M14.4444 7.99915H12.6666M14.4444 5.11023H12.6666M14.4444 10.8881H12.6666M8 14.4435V12.6658M1.55554 7.99915H3.33332M1.55554 10.8881H3.33332M1.55554 5.11023H3.33332M6 9.99915V5.99915H10V9.99915H6Z" stroke="currentColor"/>`,
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M14 8C9.83336 8 8 9.83336 8 14C8 9.83336 6.16666 8 2 8C6.16666 8 8 6.16666 8 2C8 6.16666 9.83336 8 14 8Z" stroke="currentColor"/>`,
appearance: `<path d="M2.00012 13.9997L13.9998 2" stroke="currentColor" stroke-linecap="square"/><path d="M2.00012 8.35278L8.3529 2" stroke="currentColor" stroke-linecap="square"/><path d="M2.00012 2.70586L2.70599 2" stroke="currentColor" stroke-linecap="square"/><path d="M7.64722 14.0012L14 7.64844" stroke="currentColor" stroke-linecap="square"/><path d="M13.2935 13.9988L13.9993 13.293" stroke="currentColor" stroke-linecap="square"/>`,
notifications: `<path d="M14.8889 6.77778V9.11112C14.8889 10.093 14.8889 10.8889 14.8889 10.8889H11.3334M5.55558 10.8889H2C2 10.8889 2.00001 10.093 2.00001 9.11112V3.77778C2.00001 2.79594 2.00009 2 2.00009 2H9.44531M5.55558 10.8889V13.1111H8.44447H11.3334V10.8889M5.55558 10.8889H11.3334" stroke="currentColor"/><path d="M13 5C14.1046 5 15 4.10457 15 3C15 1.89543 14.1046 1 13 1C11.8954 1 11 1.89543 11 3C11 4.10457 11.8954 5 13 5Z" stroke="currentColor"/>`,
extensions: `<path d="M8 6.75047V9.25047M10.668 6.75047V9.25047M5.33398 6.75047V9.25047M1.33398 2.44531V13.5553H14.6673V2.44531H1.33398Z" stroke="currentColor" stroke-linecap="square"/>`,
cube: `<path d="M10 2.5L16.5 6.25V13.75L10 17.5L3.5 13.75V6.25L10 2.5Z" stroke="currentColor"/><path d="M10 10L16.5 6.25M10 10V17.5M10 10L3.5 6.25" stroke="currentColor"/>`,
"post-skill": `<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" stroke="currentColor"/><path d="M5.5 7.5H10.5M5.5 10.5H14.5" stroke="currentColor"/>`,
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
}
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"
}
+1 -1
View File
@@ -18,7 +18,7 @@ const icons = {
},
folder: {
viewBox: "0 0 16 16",
body: `<path d="M2.545 3.364V12.636H13.455V5H8.545L6.909 3.364H2.545Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
body: `<path d="M1.33337 2V13.3333H14.6667V4H8.66671L6.66671 2H1.33337Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
branch: {
viewBox: "0 0 16 16",