diff --git a/packages/app/e2e/regression/project-lsp.spec.ts b/packages/app/e2e/regression/project-lsp.spec.ts new file mode 100644 index 000000000000..6061af398af6 --- /dev/null +++ b/packages/app/e2e/regression/project-lsp.spec.ts @@ -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() + 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() +}) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 3c2208f2ac82..b1afe485927e 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => { // one toggle sweeps every connected server, not just the focused one. await mockServers(page, permissionRequests, permissionResponses, { pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] }, + preferencesUnavailable: true, }) await configureServers(page) @@ -36,6 +37,8 @@ test("session settings use the remote server context", async ({ page }) => { await expect(page.getByRole("dialog")).toHaveCount(0) await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px") await expect(sessionHeading).toBeHidden() + await expect(settings.getByText("Servers", { exact: true })).toBeVisible() + await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0) const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]') const input = autoAccept.getByRole("switch") await expect(autoAccept).toBeVisible() @@ -63,9 +66,21 @@ test("session settings use the remote server context", async ({ page }) => { }, ]) + await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click() + await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible() + await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible() + await expect(settings.getByRole("tab")).toHaveText([ + "127.0.0.1:4097", + "Projects", + "Worktrees", + "Providers", + "Models", + "Extensions", + ]) await settings.getByRole("tab", { name: "Models" }).click() await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled() await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) + await settings.getByRole("button", { name: "Back to settings" }).click() await settings.getByRole("button", { name: "Back to app" }).click() await expect(settings).toBeHidden() await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) @@ -73,7 +88,7 @@ test("session settings use the remote server context", async ({ page }) => { await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title) await page.keyboard.press("Control+]") await expect(page).toHaveURL("/settings") - await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true") await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0) await page.keyboard.press("Escape") await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) @@ -311,6 +326,7 @@ type MockServerOptions = { listFailures?: Record // Records /api/session/:id GETs so tests can assert session resyncs. sessionGets?: string[] + preferencesUnavailable?: boolean } async function mockServers( @@ -402,6 +418,11 @@ async function mockServers( directory, project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory }, }) + if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200) + if (url.pathname === "/api/config/shell") + return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200) + if (url.pathname === "/api/websearch/provider") + return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200) if (url.pathname === "/api/worktree") return json(route, [{ directory }]) if (url.pathname === "/api/vcs") return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) diff --git a/packages/app/e2e/regression/server-dialog-focus.spec.ts b/packages/app/e2e/regression/server-dialog-focus.spec.ts index d68c19e20396..ba9a1716a651 100644 --- a/packages/app/e2e/regression/server-dialog-focus.spec.ts +++ b/packages/app/e2e/regression/server-dialog-focus.spec.ts @@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) => const settings = page.getByTestId("settings-screen") await expect(settings).toBeVisible() await expect(page.getByRole("dialog")).toHaveCount(0) - await settings.getByRole("tab", { name: "Servers" }).click() - await settings.getByRole("button", { name: "Add server" }).click() + const add = settings.getByRole("button", { name: "Add server" }) + const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" }) + await expect(add).toHaveCSS("opacity", "0") + await group.hover() + await expect(add).toHaveCSS("opacity", "1") + await add.click() const editor = page.getByRole("dialog", { name: "Add server" }) await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused() diff --git a/packages/app/e2e/regression/session-project-menu.spec.ts b/packages/app/e2e/regression/session-project-menu.spec.ts index 3c32189ab56a..c79761db1ec1 100644 --- a/packages/app/e2e/regression/session-project-menu.spec.ts +++ b/packages/app/e2e/regression/session-project-menu.spec.ts @@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(settings).toBeFocused() await expect(page.getByRole("tooltip")).toBeHidden() await page.keyboard.press("Enter") - const dialog = page.getByRole("dialog") - await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible() - await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue( - project.name, - ) + const settingsScreen = page.getByTestId("settings-screen") + await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible() + await expect( + settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }), + ).toHaveValue(project.name) await expect(menu).toBeHidden() - await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click() - await expect(dialog).toBeHidden() + await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click() + await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click() + await expect(settingsScreen).toBeHidden() await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) await page.setViewportSize({ width: 1440, height: 900 }) @@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) { await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled() await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled() await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click() - const dialog = page.getByRole("dialog") - await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue( - fixture.project.name, - ) - await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click() - await expect(dialog).toBeHidden() + const settingsScreen = page.getByTestId("settings-screen") + await expect( + settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }), + ).toHaveValue(fixture.project.name) + await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click() + await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click() + await expect(settingsScreen).toBeHidden() } await trigger.click() await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click() diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index bb587554780e..37212b5071f6 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -13,11 +13,20 @@ test.beforeEach(async ({ page }) => { id: "proj_settings_demo", canonical: directory, name: "Settings demo", + icon: { + color: "orange", + override: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E", + }, + commands: { start: "echo setup" }, vcs: "git", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes, }, provider: { all: [], connected: [], default: {} }, + preferences: { shell: "zsh", websearch: { provider: "exa" } }, + shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }], + websearchProviders: [{ id: "exa", name: "Exa" }], sessions: sandboxes.map((directory, index) => ({ id: `ses_settings_${index + 1}`, title: `Workspace ${index + 1} session`, @@ -56,6 +65,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() diff --git a/packages/app/e2e/regression/settings-padding.spec.ts b/packages/app/e2e/regression/settings-padding.spec.ts index 3c77c1a58177..ef9f4547ba68 100644 --- a/packages/app/e2e/regression/settings-padding.spec.ts +++ b/packages/app/e2e/regression/settings-padding.spec.ts @@ -50,12 +50,12 @@ for (const viewport of [ "Appearance", "Notifications", "Shortcuts", - "Servers", "Projects", "Worktrees", "Providers", "Models", "Extensions", + "Server", "Experimental", "About", ]) { diff --git a/packages/app/e2e/regression/settings-project-edges.spec.ts b/packages/app/e2e/regression/settings-project-edges.spec.ts index f1d8154a27c7..73daf5881bf1 100644 --- a/packages/app/e2e/regression/settings-project-edges.spec.ts +++ b/packages/app/e2e/regression/settings-project-edges.spec.ts @@ -69,11 +69,8 @@ for (const colorScheme of ["light", "dark"] as const) { await page.setViewportSize({ width: 1280, height: 720 }) await panel.getByText("rebase", { exact: true }).hover() await panel.getByText("rebase", { exact: true }).click() - const dialog = page.getByRole("dialog") - await expect(dialog.getByRole("textbox")).toHaveValue("rebase") - await expect(dialog.getByRole("textbox")).toBeFocused() - await dialog.getByRole("button", { name: "Cancel", exact: true }).click() - await expect(dialog).toBeHidden() + await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase") + await settings.getByRole("button", { name: "Back to projects", exact: true }).click() await expect(panel.getByText("rebase", { exact: true })).toBeVisible() await page.setViewportSize({ width: 1280, height: 260 }) diff --git a/packages/app/e2e/regression/settings-wsl.spec.ts b/packages/app/e2e/regression/settings-wsl.spec.ts new file mode 100644 index 000000000000..2448051313ef --- /dev/null +++ b/packages/app/e2e/regression/settings-wsl.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" + +test.use({ viewport: { width: 1280, height: 900 } }) + +for (const mode of ["failed", "stopped", "ready"] as const) { + test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => { + await mockOpenCodeServer(page, { + directory: "/repo", + project: { + id: "proj_wsl_settings", + canonical: "/repo", + name: "WSL project", + sandboxes: [], + time: { created: 1, updated: 1 }, + }, + provider: { all: [], connected: [], default: {} }, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`) + const settings = page.getByTestId("settings-screen") + await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled() + const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true }) + await expect(ubuntu).toHaveCount(1) + await ubuntu.click() + await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible() + const connection = settings.locator('[data-component="settings-server-connection"]') + + if (mode !== "ready") { + await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled() + await connection.getByRole("button", { name: "More options", exact: true }).click() + await page.getByRole("menuitem", { name: "Retry start", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu") + } + await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled() + await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu") + await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0) + + await connection.getByRole("button", { name: "More options", exact: true }).click() + await page.getByRole("menuitem", { name: "Remove", exact: true }).click() + await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu") + await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0) + await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled() + }) +} diff --git a/packages/app/e2e/regression/workspaces-prefetch.spec.ts b/packages/app/e2e/regression/workspaces-prefetch.spec.ts new file mode 100644 index 000000000000..2cec657365d1 --- /dev/null +++ b/packages/app/e2e/regression/workspaces-prefetch.spec.ts @@ -0,0 +1,219 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" + +const directory = "/repo/workspaces-prefetch" +const sandboxes = [`${directory}/first`, `${directory}/second`] +const project = { + id: "proj_workspaces_prefetch", + canonical: directory, + name: "Prefetch project", + sandboxes, + time: { created: 1, updated: 1 }, +} +const other = { ...project, id: "proj_other", name: "Other project", canonical: "/repo/other", sandboxes: [] } + +test.use({ viewport: { width: 1280, height: 900 } }) + +test.beforeEach(async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project, + provider: { all: [], connected: [], default: {} }, + sessions: [ + { + id: "ses_workspaces_cached", + title: "Cached worktree session", + projectID: project.id, + directory: sandboxes[0], + time: { created: 1, updated: 1 }, + }, + ], + 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 expect(page.getByText("Cached worktree session", { exact: true })).toBeVisible() + await page.getByRole("button", { name: "Settings", exact: true }).click() + await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible() +}) + +for (const interaction of ["hover", "focus"] as const) { + test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => { + const inventory = Promise.withResolvers() + const calls: string[] = [] + const sessions: string[] = [] + await page.route( + (url) => url.pathname === "/api/project", + (route) => route.fulfill({ json: [project, other] }), + ) + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "") + await inventory.promise + await route.fallback() + }, + ) + page.on("request", (request) => { + const url = new URL(request.url()) + if (url.pathname === "/api/session" && url.searchParams.has("directory")) + sessions.push(url.searchParams.get("directory")!) + }) + + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await settings.getByRole("button", { name: project.name, exact: true }).click() + const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true }) + await expect(worktrees).toBeEnabled() + const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project") + await worktrees[interaction]() + await requested + await expect(worktrees).toHaveAttribute("aria-selected", "false") + await expect.poll(() => calls).toEqual([directory]) + expect(sessions).toEqual([]) + + if (interaction === "hover") { + const finished = page.waitForEvent( + "requestfinished", + (request) => new URL(request.url()).pathname === "/api/worktree", + ) + inventory.resolve() + await finished + } + await worktrees.click() + await expect(worktrees).toHaveAttribute("aria-selected", "true") + inventory.resolve() + await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible() + await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible() + expect(calls).toEqual([directory]) + await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted()) + }) +} + +for (const nested of [false, true]) { + test(`${nested ? "nested" : "root"} server Worktrees hover only prefetches metadata`, async ({ page }) => { + if (nested) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript((server) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + list: [ + { type: "http", displayName: "Settings server", http: { url: server } }, + { type: "http", displayName: "Other server", http: { url: "http://127.0.0.1:4097" } }, + ], + }), + ) + }, server) + await page.reload() + await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click() + } + const calls = { projects: 0, worktrees: [] as string[] } + await page.route( + (url) => url.pathname === "/api/project", + async (route) => { + calls.projects += 1 + await route.fulfill({ json: [project, other] }) + }, + ) + await page.route( + (url) => url.pathname === "/api/worktree", + async (route) => { + const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? "" + calls.worktrees.push(requested) + if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] }) + await route.fallback() + }, + ) + const settings = page.getByTestId("settings-screen") + const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true }) + await expect(worktrees).toBeEnabled() + const fetched = page.waitForEvent( + "requestfinished", + (request) => new URL(request.url()).pathname === "/api/project", + ) + await worktrees.hover() + await fetched + await worktrees.focus() + await expect(worktrees).toHaveAttribute("aria-selected", "false") + expect(calls).toEqual({ projects: 1, worktrees: [] }) + + await worktrees.click() + await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible() + expect(calls.projects).toBe(1) + expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted()) + }) +} + +test("cached sessions render while directory sessions load without treating unknown rows as empty", async ({ + page, +}) => { + const ready = Promise.withResolvers() + await page.route( + (url) => url.pathname === "/api/session" && url.searchParams.has("directory"), + async (route) => { + await ready.promise + await route.fallback() + }, + ) + const settings = page.getByTestId("settings-screen") + const requested = page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.pathname === "/api/session" && url.searchParams.has("directory") + }) + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await requested + await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible() + const empty = settings + .locator(".settings-workspaces-row") + .filter({ has: page.getByLabel(sandboxes[1], { exact: true }) }) + await expect(empty).toContainText("Loading messages") + await settings.getByRole("button", { name: "More options", exact: true }).click() + await expect(page.getByRole("menuitem", { name: "Delete worktrees without sessions", exact: true })).toHaveCount(0) + await page.keyboard.press("Escape") + ready.resolve() + await expect(empty).toContainText("0 sessions") + await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible() +}) + +test("project deletion updates the cached server-wide inventory", async ({ page }) => { + const removed = new Set() + await page.route( + (url) => url.pathname === "/api/worktree", + (route) => { + if (route.request().method() === "DELETE") { + removed.add(route.request().postDataJSON().directory) + return route.fulfill({ status: 204 }) + } + return route.fulfill({ + json: [ + { directory }, + ...sandboxes + .filter((directory) => !removed.has(directory)) + .map((directory) => ({ directory, strategy: "git" })), + ], + }) + }, + ) + const settings = page.getByTestId("settings-screen") + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible() + await settings.getByRole("tab", { name: "Projects", exact: true }).click() + await settings.getByRole("button", { name: project.name, exact: true }).click() + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await settings.getByRole("button", { name: "Delete “second”?", exact: true }).click() + await page + .getByRole("dialog", { name: "Delete “second”?", exact: true }) + .getByRole("button", { name: "Delete worktree", exact: true }) + .click() + await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible() + await settings.getByRole("button", { name: "Back to projects", exact: true }).click() + await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() + await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible() + await expect(settings.getByLabel(sandboxes[1], { exact: true })).toHaveCount(0) +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 414f8ed63178..2a2760b66894 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -70,7 +70,23 @@ const Group = HttpApiGroup.make("mock") .add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json })) .add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json })) .add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json })) + .add( + HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", { + params: { projectID: Schema.String }, + payload: JsonPayload, + success: Json, + }), + ) .add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json })) + .add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json })) + .add( + HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", { + payload: JsonPayload, + success: Json, + }), + ) + .add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json })) + .add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json })) .add( HttpApiEndpoint.get("worktreeList", "/api/worktree", { success: Json, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 71ca5540e0a7..09c767268cf1 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -9,6 +9,9 @@ export interface MockServerConfig { provider: unknown | (() => unknown) integrationMethods?: Record onConnectKey?: (input: { integrationID: string; body: unknown }) => void + preferences?: Record + shells?: unknown[] + websearchProviders?: unknown[] directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -185,13 +188,14 @@ export function createMockServerHandler(config: MockServerConfig) { const corsHeaders = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", - "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "access-control-expose-headers": "x-next-cursor", } function mockHandlers(config: MockServerConfig, state: { cursors: Map; nextCursor: number }) { const noContent = Effect.succeed(HttpApiSchema.NoContent.make()) const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay)) + const preferences = { current: config.preferences ?? {} } return HttpApiBuilder.group(MockApi, "mock", (handlers) => handlers .handleRaw("event", () => { @@ -269,12 +273,29 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map { + 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 }, diff --git a/packages/app/e2e/utils/settings-wsl.fixture.tsx b/packages/app/e2e/utils/settings-wsl.fixture.tsx new file mode 100644 index 000000000000..da33aa2c96cc --- /dev/null +++ b/packages/app/e2e/utils/settings-wsl.fixture.tsx @@ -0,0 +1,135 @@ +import { MemoryRouter, createMemoryHistory } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import { createStore, unwrap } from "solid-js/store" +import { render } from "solid-js/web" +import { AppBaseProviders, AppInterface } from "../../src/app" +import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform" +import { ServerConnection } from "../../src/runtime/server/registry" +import { useWslServers } from "../../src/servers/wsl/context" +import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types" + +export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) { + const root = document.getElementById("root") + if (!root) throw new Error("Missing fixture root") + const history = createMemoryHistory() + history.set({ value: "/settings", replace: true, scroll: false }) + render(() => { + const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({ + calls: [], + state: { + runtime: { available: true, version: "2", error: null }, + installed: [], + online: [], + distroProbes: {}, + pendingRestart: false, + job: null, + servers: [ + { + config: { id: "wsl:Ubuntu", distro: "Ubuntu" }, + runtime: + input.mode === "ready" + ? { kind: "ready", url: input.server, password: null } + : input.mode === "failed" + ? { kind: "failed", message: "WSL failed to start" } + : { kind: "stopped" }, + }, + ], + opencodeChecks: { + Ubuntu: { + distro: "Ubuntu", + resolvedPath: "/usr/bin/opencode", + version: "old", + expectedVersion: "current", + matchesDesktop: false, + error: null, + }, + }, + }, + }) + const listeners = new Set<(event: WslServersEvent) => void>() + const publish = () => + listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) })) + const unused = async () => { + throw new Error("Unexpected fixture action") + } + const wsl: WslServersPlatform = { + getState: async () => structuredClone(unwrap(store.state)), + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + probeRuntime: unused, + refreshDistros: unused, + installWsl: unused, + installDistro: unused, + probeAddable: unused, + openTerminal: unused, + addServer: unused, + async installOpencode(distro) { + setStore("calls", (calls) => [...calls, `update:${distro}`]) + setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true }) + publish() + }, + async startServer(id) { + setStore("calls", (calls) => [...calls, `start:${id}`]) + setStore("state", "servers", (server) => server.config.id === id, "runtime", { + kind: "ready", + url: input.server, + password: null, + }) + publish() + }, + async removeServer(id) { + setStore("calls", (calls) => [...calls, `remove:${id}`]) + setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id)) + publish() + }, + } + const platform: Platform = { + platform: "desktop", + os: "windows", + windowID: "settings-wsl-test", + openExternal: () => undefined, + openDirectoryPickerDialog: async () => null, + notify: async () => undefined, + restart: unused, + wslServers: wsl, + } + function Interface() { + const wsl = useWslServers() + const servers = createMemo(() => [ + { type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } }, + ...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] => + item.runtime.kind === "ready" + ? [ + { + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + displayName: item.config.distro, + http: { url: item.runtime.url }, + }, + ] + : [], + ), + ]) + return ( + + } + /> + + ) + } + return ( + + + {store.calls.join(",")} + + + + ) + }, root) +} diff --git a/packages/app/e2e/utils/settings-wsl.html b/packages/app/e2e/utils/settings-wsl.html new file mode 100644 index 000000000000..02fdae1d3a53 --- /dev/null +++ b/packages/app/e2e/utils/settings-wsl.html @@ -0,0 +1,14 @@ + + + + + + +
+ + + diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 6fe3ab39f2dd..045f374034cc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ reuseExistingServer: !built, timeout: 120_000, env: { + VITE_OPENCODE_TEST_FIXTURES: "1", VITE_OPENCODE_SERVER_HOST: serverHost, VITE_OPENCODE_SERVER_PORT: serverPort, }, diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 5c3f2468d425..070c4e95c163 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -1,6 +1,7 @@ import { useDirectoryPicker } from "@/workspaces/selection/picker" import { useServerActionsController } from "@/servers/registry/controller" import { useSettingsCommand } from "@/settings/command" +import { useSettingsSurface } from "@/settings/surface" import { type LocalProject } from "@/shell/state/layout" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" @@ -27,6 +28,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 authenticate = useSshAuthenticate() @@ -131,8 +133,9 @@ export function createHomeProjectsController(home: HomeController) { }) }, edit: (conn: ServerConnection.Any, project: LocalProject) => { - void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => { - void dialog.show(() => ) + settings.openProject({ + server: ServerConnection.key(conn), + project: project.worktree, }) }, unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 3c76ec192300..756b719ea42e 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -1,7 +1,8 @@ import { createPromptProjectController } from "@/new-session/project/selector" -import { useSettingsDialog } from "@/settings/command" +import { useSettingsSurface } from "@/settings/surface" import { useSettings } from "@/settings/model" import { useTabs, type DraftTab } from "@/shell/tabs/tabs" +import { useSettingsServers } from "@/settings/servers/inventory" import { useSearchParams } from "@solidjs/router" import { createEffect, createMemo, createResource, untrack } from "solid-js" import { createComposerModel } from "@/composer/model" @@ -16,10 +17,19 @@ export default function NewSessionPage(props: { draftId: string }) { const settings = useSettings() const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>() const tabs = useTabs() - const openWorkspaces = useSettingsDialog("workspaces") + const servers = useSettingsServers() + const settingsSurface = useSettingsSurface() const draftTab = createMemo(() => tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), ) + const openWorkspaces = () => { + const draft = draftTab() + if (servers().length > 1 && draft) { + settingsSurface.openServer(draft.server, "workspaces") + return + } + settingsSurface.open("workspaces") + } const workspace = createNewSessionWorkspaceController({ selectedWorktree: () => draftTab()?.worktree, selectedBranch: () => draftTab()?.branch, diff --git a/packages/app/src/providers/models/manage.tsx b/packages/app/src/providers/models/manage.tsx index 808621f6f282..d278ac759ea1 100644 --- a/packages/app/src/providers/models/manage.tsx +++ b/packages/app/src/providers/models/manage.tsx @@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => { - {group.items[0].provider.name} + {group.items[0].provider.name} { - + {(item) => ( diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 0152ca0c8fac..26f8789eeaee 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -162,12 +162,12 @@ const ModelList: Component<{ - {group.items[0].provider.name} + {group.items[0].provider.name} - + {(item) => ( + + {language.t("dialog.server.add.button")} + + } + > + } + aria-label={language.t("dialog.server.add.button")} + onClick={props.onAddServer} + /> + } > - - {language.t("dialog.server.add.button")} - + + {language.t("dialog.server.add.button")} + + } + > + } + aria-label={language.t("dialog.server.add.button")} + /> + {language.t("dialog.server.add.button")} @@ -72,7 +99,7 @@ export function useFilteredWslServers(filter: Accessor) { export function WslServerSettings(props: { domain: Pick - servers: ReturnType + servers: Accessor }) { const platform = usePlatform() const language = useLanguage() @@ -160,7 +187,9 @@ export function WslServerSettings(props: { - remove(key)}>{language.t("dialog.server.menu.delete")} + remove(key)}> + {language.t("dialog.server.menu.remove")} + diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 879199dc7162..c17afc9d265c 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -1,5 +1,4 @@ import type { SessionInfo } from "@opencode/client/promise" -import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" import { IconButton } from "@opencode/ui/icon-button" import { Menu } from "@opencode/ui/menu" @@ -10,12 +9,14 @@ import { useNavigate } from "@solidjs/router" import { createMemo, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { useServer } from "@/runtime/server/current" +import { ServerConnection } 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(() => ) + settingsSurface.openProject({ + server: ServerConnection.key(server.conn), + project: current.worktree, + }) } return ( @@ -191,7 +194,7 @@ export function SessionProjectMenu(props: { - void openProjectSettings()}> + {language.t("project.settings.title")} diff --git a/packages/app/src/settings/command.tsx b/packages/app/src/settings/command.tsx index 4258c7d3d3f4..1e8601d5dbe1 100644 --- a/packages/app/src/settings/command.tsx +++ b/packages/app/src/settings/command.tsx @@ -1,8 +1,9 @@ import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSettingsSurface } from "./surface" +import type { SettingsRootTab } from "./surface" -export function useSettingsDialog(defaultValue?: string) { +export function useSettingsDialog(defaultValue?: SettingsRootTab) { const settings = useSettingsSurface() return () => settings.open(defaultValue) } diff --git a/packages/app/src/settings/general/controllers.ts b/packages/app/src/settings/general/controllers.ts index 8d878c432232..9538f2b38f04 100644 --- a/packages/app/src/settings/general/controllers.ts +++ b/packages/app/src/settings/general/controllers.ts @@ -1,4 +1,5 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ConfigPreferences, 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) { +export function createServerPreferencesController(server: Accessor) { + const language = useLanguage() const serverCtx = useServerCtx(server) + const source = () => ServerConnection.key(server()) + const [preferences, preferencesActions] = createResource( + source, + () => + serverCtx() + .sdk.api.config.preferences() + .catch(() => ({})), + { initialValue: {} }, + ) 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[] }, + source, + () => + serverCtx() + .sdk.api.config.shells() + .catch(() => []), + { initialValue: [] }, + ) + const [providers] = createResource( + source, + () => + serverCtx() + .sdk.api.websearch.providers() + .then((result) => result.data) + .catch(() => []), + { initialValue: [] }, ) - const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "") + + 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 +export type ShellSettingsController = ReturnType["shell"] export type AppearanceSettingsController = ReturnType export type SoundSettingsController = ReturnType diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index fbf0d9d01e0c..34ac8216ed4f 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -21,12 +21,10 @@ import { SettingsRow } from "@/settings/row" import { createAppearanceSettingsController, createShellOptions, - createShellSettingsController, type AppearanceSettingsController, type ShellSettingsController, } from "./controllers" import "@/settings/settings.css" -import { ServerConnection } from "@/runtime/server/registry" const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] const fontSettings = { @@ -97,7 +95,7 @@ const WorkspaceDestinationSetting: Component = () => { ) } -const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { +export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const language = useLanguage() const options = createMemo(() => createShellOptions({ @@ -295,15 +293,12 @@ const LanguageSetting = () => { ) } -export const SettingsGeneral: Component<{ - server?: ServerConnection.Any -}> = (props) => { +export const SettingsGeneral: Component = () => { const language = useLanguage() const platform = usePlatform() const settings = useSettings() const mobile = createMediaQuery("(max-width: 767px)") const updater = useUpdaterAction() - const shell = createShellSettingsController(() => props.server) const desktop = createMemo(() => platform.platform === "desktop") const [pinchZoom, { mutate: setPinchZoom }] = createResource( @@ -328,10 +323,32 @@ export const SettingsGeneral: Component<{ - + +
+ settings.general.setShowCustomAgents(checked)} + /> +
+
+ + + +
+ +
+
+
+ {language.t("settings.general.section.advanced")} - -
- settings.general.setShowSearch(checked)} - /> -
-
- - - -
- settings.general.setShowCustomAgents(checked)} - /> -
-
) @@ -482,26 +475,6 @@ export const SettingsGeneral: Component<{ ) - // We can probably remove this, right? - const DisplaySection = () => ( - -
-

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

- - - -
- -
-
-
-
-
- ) - return ( <>
@@ -514,7 +487,7 @@ export const SettingsGeneral: Component<{
-
+
@@ -533,8 +506,6 @@ export const SettingsGeneral: Component<{ - -
diff --git a/packages/app/src/settings/keybinds/keybinds.tsx b/packages/app/src/settings/keybinds/keybinds.tsx index a6d03d5f1441..cc76c564dcc3 100644 --- a/packages/app/src/settings/keybinds/keybinds.tsx +++ b/packages/app/src/settings/keybinds/keybinds.tsx @@ -417,7 +417,7 @@ function SettingsKeybindsView(props: {
-
+
{(group) => ( 0}> diff --git a/packages/app/src/settings/list.tsx b/packages/app/src/settings/list.tsx index dd13f24ae293..b7c3251d9972 100644 --- a/packages/app/src/settings/list.tsx +++ b/packages/app/src/settings/list.tsx @@ -1,6 +1,10 @@ import type { Component, JSX } from "solid-js" import "@/settings/settings.css" -export const SettingsList: Component<{ children: JSX.Element }> = (props) => { - return
{props.children}
+export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => { + return ( +
+ {props.children} +
+ ) } diff --git a/packages/app/src/settings/models/models.tsx b/packages/app/src/settings/models/models.tsx index d51ccf1b262c..61c6475271fd 100644 --- a/packages/app/src/settings/models/models.tsx +++ b/packages/app/src/settings/models/models.tsx @@ -12,7 +12,6 @@ import { useModels } from "@/providers/models/models" import { useServerSDK } from "@/runtime/server/client" import { popularProviders } from "@/providers/catalog/providers" import { Persist, persisted } from "@/runtime/persistence/storage" -import { InlineServerSelect } from "@/settings/server-select" import { SettingsList } from "@/settings/list" import { SettingsRow } from "@/settings/row" import "@/settings/settings.css" @@ -65,7 +64,6 @@ export const SettingsModels: Component = () => {

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

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

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

diff --git a/packages/app/src/settings/providers/extensions.tsx b/packages/app/src/settings/providers/extensions.tsx index b34bebd94458..00ce32b341b1 100644 --- a/packages/app/src/settings/providers/extensions.tsx +++ b/packages/app/src/settings/providers/extensions.tsx @@ -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 = () => {

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

{language.t("settings.extensions.description")}
-
- + {language.t("settings.extensions.tab.mcps")} {language.t("status.popover.tab.plugins")} @@ -78,18 +77,20 @@ export const SettingsExtensions: Component = () => {
- + {language.t("settings.extensions.availableAll")} - {language.t("settings.extensions.manageConfig")} + + {language.t("settings.extensions.manageConfig")} +
-
+ {(item) => ( -
-
+
+
- {item.name} + {item.name}
handleMcpToggle(item, checked)} hideLabel> {item.name} @@ -97,58 +98,57 @@ export const SettingsExtensions: Component = () => {
)} -
+
- + {language.t("settings.extensions.availableAll")} - {language.t("settings.extensions.manageConfig")} + + {language.t("settings.extensions.manageConfig")} +
-
+ {(plugin) => ( -
-
+
+
- {plugin.name} + {plugin.name}
)} -
+
- + {language.t("settings.extensions.availableAll")} - + {language.t("settings.extensions.addSkills")}
-
+ {(skill) => ( -
-
+
+
- {skill.name} + {skill.name}
)} -
+
diff --git a/packages/app/src/settings/providers/providers.tsx b/packages/app/src/settings/providers/providers.tsx index a9d841f53a25..cc21e15fc920 100644 --- a/packages/app/src/settings/providers/providers.tsx +++ b/packages/app/src/settings/providers/providers.tsx @@ -9,8 +9,6 @@ import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { useServerSDK } from "@/runtime/server/client" import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog" -import { SettingsServerScope } from "@/settings/server-scope" -import { InlineServerSelect } from "@/settings/server-select" import { SettingsList } from "@/settings/list" import "@/settings/settings.css" @@ -44,11 +42,7 @@ export const SettingsProviders: Component<{ const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ( - - - - )) + void dialog.show(() => ) } const connected = createMemo(() => { @@ -134,14 +128,13 @@ export const SettingsProviders: Component<{

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

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

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

- + 0} fallback={
{language.t("settings.providers.connected.empty")}
} @@ -182,7 +175,7 @@ export const SettingsProviders: Component<{

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

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

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

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

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

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

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

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

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

+ + + + +
+ ) +} diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 74ac6a4790b3..873c2a9cf9e9 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -56,8 +56,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 { @@ -72,6 +71,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; } @@ -80,6 +129,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; @@ -251,8 +307,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 { @@ -273,9 +339,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 { @@ -295,16 +365,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"] { @@ -314,6 +386,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); @@ -324,7 +402,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); } @@ -377,6 +455,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; @@ -467,8 +551,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); } @@ -507,7 +590,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); } @@ -536,7 +619,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)) { @@ -562,12 +645,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; @@ -575,7 +658,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); @@ -614,30 +697,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 { @@ -730,25 +793,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; } @@ -794,17 +848,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; @@ -893,39 +936,23 @@ 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 { +.settings-servers-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-server-connection [data-component="settings-list"] { + padding-inline: 16px; } -.settings-servers [data-component="settings-list"] { - display: flex; - flex-direction: column; - gap: 0; - padding: 20px; - border-radius: 6px; +.settings-server-connection .settings-servers-row { + padding-block: 20px; } -.settings-servers-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; +.settings-server-connection .settings-servers-lead { + gap: 4px; } .settings-servers-row:not(:last-child) { @@ -972,34 +999,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 { @@ -1008,13 +1009,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 { @@ -1257,7 +1252,7 @@ @media (max-width: 639px) { .settings-workspaces-header { - padding: 24px 20px 20px; + padding: 24px 20px 0; } .settings-tab-body.settings-workspaces { @@ -1373,20 +1368,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; diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 4c7439851964..9d66757ebdd2 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -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,101 @@ 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 { useWorkspacesPrefetch } from "./workspaces/queries" 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 }) + onMount(() => root?.focus({ preventScroll: true })) + createEffect(() => { + const next = surface.view().type + if (next === viewType) return + viewType = next + queueMicrotask(() => root?.focus({ preventScroll: true })) }) - const server = createMemo(() => { - const route = surface.route() - switch (route.type) { - case "draft": { - const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) - return servers.list.find((item) => ServerConnection.key(item) === draft?.server) - } - case "session": - return servers.list.find((item) => ServerConnection.key(item) === route.server) - case "home": - return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server) - } + const connection = (key: string) => servers().find((item) => item.key === key) + const project = (server: ServerConnection.Any, directory: string) => { + const context = global.ensureServerCtx(server) + const value = + context.projects.list().find((item) => item.worktree === directory) ?? + context.sync.data.project.find((item) => item.worktree === directory) + return value ? { expanded: false, ...value } : undefined + } + const targetServer = createMemo(() => { + const view = surface.view() + if (view.type === "root") return undefined + return connection(view.server) }) - const serverCtx = useServerCtx(server) - - createEffect(() => { - const current = server() - if (current) global.settings.server.set(ServerConnection.key(current)) + 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) }) - - 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 + createEffect(() => { + const view = surface.view() + if (view.type === "root") return + const target = targetServer() + if (!target) { + surface.back() + return } - if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory - return undefined + 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) }) - - const showProviders = () => { - dialog.close() - surface.open("providers") - } return (
{ onKeyDown={(event) => { if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return event.preventDefault() - surface.close() + surface.back() }} > - void startTransition(() => surface.open(value))} - class="settings" - > -
- - - - - {language.t( - sections.flat().find((section) => section.value === surface.tab())?.label ?? - "settings.tab.preferences", - )} - - - - - event.stopPropagation()}> - void startTransition(() => surface.open(value))} - > - - {(group, index) => ( - <> - 0}> - - - - {(section) => ( - { - if (section.value === "workspaces") - setState("worktreeFilterReset", (value) => value + 1) - }} - > - - {language.t(section.label)} - - )} - - - )} - - - - - -
- -
- -
- - {(group) => ( -
- - {(section) => ( - { - if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1) - }} - > - - {language.t(section.label)} - - )} - -
- )} -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - state.worktreeFilterReset} - /> + + + + + + {(server) => } + + + + {(server) => ( + {(project) => } + )} + + + +
+ ) +} + +function RootSettings() { + const language = useLanguage() + const dialog = useDialog() + const surface = useSettingsSurface() + const layout = useLayout() + const tabs = useTabs() + const servers = useServerCollectionController() + const inventory = useSettingsServers() + const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const list = servers.collection.items + const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined)) + const single = createMemo(() => singleEntry()?.connection) + const prefetchWorkspaces = useWorkspacesPrefetch(single) + 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(() => ( + surface.openServer(ServerConnection.key(server))} /> + )) + const groups = createMemo(() => [ + { items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) }, + ...(multiple() + ? [ + { + label: language.t("status.popover.tab.servers"), + action: , + items: ordered().map((server) => ({ + value: `server:${server.key}`, + icon: "server" as const, + label: server.name, + })), + }, + ] + : [ + { + items: [ + ...serverTabs.map((item) => ({ + ...item, + label: language.t(item.label), + disabled: !single(), + onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined, + })), + { 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 ( + surface.close()} + onChange={change} + mobileAction={multiple() ? : undefined} + > + + + + + + + + + + + + + + + + + + + + {(server) => ( + + + + surface.openProject({ + server: ServerConnection.key(server), + project: project.worktree, + }) + } + /> + + + state.worktreeFilterReset} + /> + + + surface.select("providers")} /> + + + + + + + + + )} + + + {(entry) => ( + + - - + )} + + + ) +} + +function ServerSettings(props: { entry: SettingsServer }) { + const language = useLanguage() + const surface = useSettingsSurface() + const activeDirectory = useSettingsDirectory(() => props.entry.connection) + const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection) + const [state, setState] = createStore({ worktreeFilterReset: 0 }) + const groups = createMemo(() => [ + { + items: nestedServerTabs.map((item) => ({ + ...item, + label: item.value === "general" ? props.entry.name : language.t(item.label), + disabled: item.value !== "general" && !props.entry.connection, + onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined, + })), + }, + ]) + 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 ( + surface.back()} + onChange={change} + > + + surface.replaceServer(ServerConnection.key(server))} + /> + + + {(server) => ( + + + + surface.openProject({ + server: props.entry.key, + project: project.worktree, + }) + } + /> + + + state.worktreeFilterReset} + /> + + + surface.select("providers")} /> + + + + + + + + + )} + + + ) +} + +function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) { + const language = useLanguage() + const surface = useSettingsSurface() + const activeDirectory = useSettingsDirectory(() => props.server) + const prefetchWorkspaces = useWorkspacesPrefetch( + () => props.server, + () => props.project.id, + ) + const groups: SettingsNavGroup[] = [ + { + items: nestedProjectTabs.map((item) => ({ + ...item, + onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined, + get label() { + return item.value === "general" ? displayName(props.project) : language.t(item.label) + }, + })), + }, + ] + return ( + + + surface.back()} + onChange={(value) => surface.select(value)} + > + + surface.replaceServer(ServerConnection.key(props.server))} + /> - - + + - + - - - - - -
+ + + ) } + +function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) { + return list.find((item) => ServerConnection.key(item) === key) +} + +function useSettingsDirectory(server: Accessor) { + 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 + }) +} diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index c2188e39c711..07b4a856fae6 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -3,6 +3,75 @@ import { createEffect, on } from "solid-js" import { createSimpleContext } from "@opencode/ui/context" import { useLayout, type LayoutRoute } from "@/shell/state/layout" import { useCommand } from "@/shell/commands/command" +import { useSettingsServers } from "./servers/inventory" + +export type SettingsRootTab = + | "general" + | "appearance" + | "notifications" + | "shortcuts" + | "projects" + | "workspaces" + | "providers" + | "models" + | "extensions" + | "servers" + | "experimental" + | "about" + +export type SettingsServerTab = "general" | "projects" | "workspaces" | "providers" | "models" | "extensions" +export type SettingsProjectTab = "general" | "workspaces" | "extensions" + +export type SettingsView = + | { type: "root"; tab: SettingsRootTab } + | { type: "server"; server: string; tab: SettingsServerTab } + | { + type: "project" + server: string + project: string + tab: SettingsProjectTab + parent: "root" | "server" + } + +const rootTabs: Record = { + general: true, + appearance: true, + notifications: true, + shortcuts: true, + projects: true, + workspaces: true, + providers: true, + models: true, + extensions: true, + servers: true, + experimental: true, + about: true, +} +const serverTabs: Record = { + general: true, + projects: true, + workspaces: true, + providers: true, + models: true, + extensions: true, +} +const projectTabs: Record = { + general: true, + workspaces: true, + extensions: true, +} + +function isRootTab(value: string): value is SettingsRootTab { + return value in rootTabs +} + +function isServerTab(value: string): value is SettingsServerTab { + return value in serverTabs +} + +function isProjectTab(value: string): value is SettingsProjectTab { + return value in projectTabs +} export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({ name: "SettingsSurface", @@ -11,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; tab: string } + settings?: { route: Exclude; view: SettingsView } }>() const open = () => layout.route().type === "settings" const source = () => location.state?.settings?.route ?? { type: "home" as const } + const view = (): SettingsView => location.state?.settings?.view ?? { type: "root", tab: "general" } let focus: HTMLElement | undefined + 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") diff --git a/packages/app/src/settings/workspaces/project-dialog.css b/packages/app/src/settings/workspaces/project-dialog.css deleted file mode 100644 index d83fb06f947e..000000000000 --- a/packages/app/src/settings/workspaces/project-dialog.css +++ /dev/null @@ -1,238 +0,0 @@ -.project-settings-dialog [data-slot="dialog-container"] { - background: var(--v2-background-bg-base); -} - -.project-settings-dialog [data-slot="dialog-body"] { - padding: 0; - overflow: hidden; -} - -.project-settings-v2 { - height: 100%; -} - -.project-settings-nav { - display: flex; - flex-direction: column; - gap: 4px; - width: 100%; -} - -.project-settings-panel { - display: flex; - flex-direction: column; - height: 100%; - min-width: 0; - overflow: hidden !important; - user-select: none; -} - -.project-settings-panel :is(input, textarea, [contenteditable="true"]) { - user-select: text; -} - -.project-settings-form { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.project-settings-scroll { - display: flex; - flex: 1; - flex-direction: column; - gap: 24px; - min-height: 0; - padding: 40px; - overflow-y: auto; -} - -.project-settings-page-header { - display: flex; - flex-direction: column; - gap: 6px; -} - -.project-settings-page-header h2 { - color: var(--v2-text-text-base); - font-size: 15px; - font-weight: 640; - line-height: 1; -} - -.project-settings-page-header > span { - color: var(--v2-text-text-muted); - font-size: 11px; - font-weight: 440; - line-height: 1; -} - -.project-settings-extensions { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - padding: 40px; -} - -.project-settings-extension-tabs { - flex: 1; - height: auto; - min-height: 0; - margin-top: 24px; - overflow: visible; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] { - width: auto; - padding-inline: 0 !important; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before { - display: none; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] - > [data-slot="tabs-v2-list"] - [data-slot="tabs-v2-trigger-wrapper"] { - border-radius: 6px; -} - -.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] - > [data-slot="tabs-v2-list"] - [data-slot="tabs-v2-trigger"] { - padding-inline: 8px; -} - -.project-settings-extension-tabs [data-slot="tabs-v2-content"] { - overflow-y: auto; -} - -.project-settings-extension-section { - display: flex; - flex-direction: column; - gap: 16px; - padding-top: 20px; -} - -.project-settings-extension-section-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - line-height: var(--line-height-compact); -} - -.project-settings-extension-section-header > :last-child { - color: var(--v2-text-text-faint); - font-size: 11px; - font-weight: 440; -} - -.project-settings-extension-link { - color: var(--v2-text-text-accent) !important; - text-decoration: none; -} - -.project-settings-extension-link:hover { - text-decoration: underline; -} - -.project-settings-extension-card { - padding-inline: 12px; - overflow: hidden; - border: 0.5px solid var(--v2-border-border-base); - border-radius: 8px; - background: var(--v2-background-bg-base); -} - -.project-settings-extension-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 48px; - border-bottom: 0.5px solid var(--v2-border-border-base); -} - -.project-settings-extension-row:last-child { - border-bottom: 0; -} - -.project-settings-extension-row-main { - display: flex; - flex: 1; - align-items: center; - gap: 10px; - min-width: 0; -} - -.project-settings-extension-row-icon { - flex-shrink: 0; - color: var(--v2-icon-icon-muted); -} - -.project-settings-extension-row-name { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - text-overflow: ellipsis; - white-space: nowrap; -} - -.project-settings-extension-row-status { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - color: var(--v2-text-text-muted); - font-size: 11px; -} - -.project-settings-extension-row-status-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--v2-state-fg-warning); -} - -.project-settings-shared { - display: flex; - flex-direction: column; - gap: 12px; -} - -.project-settings-shared-trigger { - display: flex; - align-items: center; - align-self: flex-start; - gap: 6px; - color: var(--v2-text-text-base); - font-size: 11px; - font-weight: 530; -} - -.project-settings-shared-chevron { - width: 12px; - height: 12px; - color: var(--v2-icon-icon-muted); - transition: transform 120ms ease; -} - -.project-settings-shared-chevron.open { - transform: rotate(90deg); -} - -.project-settings-shared-count { - padding: 1px 4px; - border-radius: 3px; - background: var(--v2-background-bg-layer-02); - color: var(--v2-text-text-muted); - font-size: 9px; -} diff --git a/packages/app/src/settings/workspaces/project-dialog.tsx b/packages/app/src/settings/workspaces/project-dialog.tsx deleted file mode 100644 index c500f592a81e..000000000000 --- a/packages/app/src/settings/workspaces/project-dialog.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { Dialog, DialogFooter } from "@opencode/ui/dialog" -import { Field } from "@opencode/ui/field" -import { Icon } from "@opencode/ui/icon" -import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar" -import { Tabs } from "@opencode/ui/tabs" -import { Textarea } from "@opencode/ui/textarea" -import { TextInput } from "@opencode/ui/text-input" -import { For, Show, createSignal, startTransition } from "solid-js" -import { useLanguage } from "@/runtime/i18n/language" -import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout" -import { ServerConnection } from "@/runtime/server/registry" -import { LocationProvider } from "@/workspaces/location" -import { displayName } from "@/shell/layout/helpers" -import { ProjectIcon } from "@/shell/layout/project-icon" -import { createEditProjectModel } from "./project-model" -import { ProjectSettingsExtensions } from "./project-extensions" -import { SettingsServerDataScope } from "@/settings/server-scope" -import "@/settings/settings.css" -import "./project-dialog.css" - -export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { - return ( - - - - - - ) -} - -function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) { - const language = useLanguage() - const model = createEditProjectModel(props) - const projectName = () => displayName(props.project) - const [tab, setTab] = createSignal("general") - - const Footer = () => ( - - - - - ) - - return ( - - void startTransition(() => setTab(value))} - class="project-settings-v2" - > - -
- - - {projectName()} - - - - {language.t("project.settings.scripts")} - - - - {language.t("settings.tab.extensions")} - -
-
- - -
-
-
-

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

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