mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 03:16:23 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5518553b5 | ||
|
|
2e8ed86658 | ||
|
|
6eb2042acd | ||
|
|
54504ab3a5 | ||
|
|
cc5086d127 | ||
|
|
b20482461c | ||
|
|
5b5368fe98 |
@@ -1,129 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ConfigEntry } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/configured-lsp"
|
||||
const entries: ConfigEntry[] = [
|
||||
{
|
||||
type: "document",
|
||||
path: "/config/opencode.json",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
path: `${directory}/opencode.jsonc`,
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_configured_lsp",
|
||||
canonical: directory,
|
||||
name: "Configured LSP project",
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
})
|
||||
|
||||
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
async (route) => {
|
||||
await ready.promise
|
||||
await route.fulfill({ json: entries })
|
||||
},
|
||||
)
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
|
||||
ready.resolve()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
|
||||
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
|
||||
await expect(typescript).toContainText("Disabled in config")
|
||||
await expect(typescript).toContainText(".ts, .tsx")
|
||||
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
|
||||
"Enabled in config",
|
||||
)
|
||||
await expect(panel.getByRole("switch")).toHaveCount(0)
|
||||
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
for (const lsp of [true, false]) {
|
||||
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp } }],
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(
|
||||
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
|
||||
const state = { fail: true }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: state.fail ? 404 : 200,
|
||||
json: state.fail ? {} : entries,
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
|
||||
state.fail = false
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
|
||||
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -20,7 +20,6 @@ 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)
|
||||
|
||||
@@ -35,10 +34,8 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
|
||||
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()
|
||||
@@ -66,21 +63,9 @@ 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}`)
|
||||
@@ -88,7 +73,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: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("tab", { name: "Models", 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}`)
|
||||
@@ -326,7 +311,6 @@ type MockServerOptions = {
|
||||
listFailures?: Record<string, number>
|
||||
// Records /api/session/:id GETs so tests can assert session resyncs.
|
||||
sessionGets?: string[]
|
||||
preferencesUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function mockServers(
|
||||
@@ -418,11 +402,6 @@ async function mockServers(
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/config/shell")
|
||||
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/websearch/provider")
|
||||
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
|
||||
@@ -27,12 +27,8 @@ 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)
|
||||
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()
|
||||
await settings.getByRole("tab", { name: "Servers" }).click()
|
||||
await settings.getByRole("button", { name: "Add server" }).click()
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
|
||||
@@ -198,15 +198,14 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
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)
|
||||
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,
|
||||
)
|
||||
await expect(menu).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 dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
@@ -273,13 +272,12 @@ 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 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()
|
||||
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()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
|
||||
@@ -122,7 +122,15 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -13,20 +13,11 @@ 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`,
|
||||
@@ -65,134 +56,9 @@ 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.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Control+t")
|
||||
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
|
||||
|
||||
@@ -22,9 +22,7 @@ test.beforeEach(async ({ page }) => {
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(
|
||||
page.getByTestId("settings-screen").getByRole("combobox", { name: "Search settings", exact: true }),
|
||||
).toBeFocused()
|
||||
await expect(page.getByTestId("settings-screen")).toBeFocused()
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
@@ -40,7 +38,7 @@ for (const viewport of [
|
||||
test("every settings page leaves room below its final content", async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.locator(".settings-content > .settings-panel:visible")
|
||||
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
|
||||
if (viewport.bottom) {
|
||||
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
@@ -52,12 +50,12 @@ for (const viewport of [
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Shortcuts",
|
||||
"Servers",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Server",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
|
||||
@@ -69,8 +69,11 @@ 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()
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase")
|
||||
await settings.getByRole("button", { name: "Back to projects", 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(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 260 })
|
||||
|
||||
@@ -31,7 +31,7 @@ for (const viewport of [
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
const main = page.getByRole("main")
|
||||
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
|
||||
await expect(settings.getByRole("combobox", { name: "Search settings", exact: true })).toBeFocused()
|
||||
await expect(settings).toBeFocused()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/)
|
||||
await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1)
|
||||
|
||||
@@ -1,387 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { clientSettings } from "../../src/settings/search-catalog"
|
||||
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const directory = "/projects/opencode"
|
||||
const config = {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_search",
|
||||
canonical: directory,
|
||||
name: "OpenCode",
|
||||
icon: { color: "orange" },
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
function projectList(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...config.project,
|
||||
id: `proj_search_${index}`,
|
||||
canonical: `${directory}-${index}`,
|
||||
name: `OpenCode ${String(index).padStart(2, "0")}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function ui(page: Page) {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
return {
|
||||
settings,
|
||||
search: settings.getByRole("combobox", { name: "Search settings", exact: true }),
|
||||
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
|
||||
}
|
||||
}
|
||||
|
||||
async function findShortcut(page: Page) {
|
||||
// Browser emulation can report a different OS than the machine running Playwright.
|
||||
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
|
||||
await page.keyboard.press(key)
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, config)
|
||||
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
|
||||
await page.goto("/")
|
||||
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const view = ui(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
// Readiness includes the server-backed project inventory, not just the settings shell.
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.search.clear()
|
||||
})
|
||||
|
||||
test("autofocus, pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
|
||||
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
|
||||
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
|
||||
await expect(view.results.getByRole("option")).toHaveText([
|
||||
"Code FontAppearance",
|
||||
"Terminal FontAppearance",
|
||||
"UI FontAppearance",
|
||||
])
|
||||
await code.click()
|
||||
await expect(code).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
|
||||
await code.press("ArrowDown")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await terminal.press("End")
|
||||
await expect(font).toBeFocused()
|
||||
await font.press("Home")
|
||||
await expect(code).toBeFocused()
|
||||
await terminal.focus()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
|
||||
0, 4,
|
||||
])
|
||||
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(view.settings).toBeHidden()
|
||||
await findShortcut(page)
|
||||
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
|
||||
})
|
||||
|
||||
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("work")
|
||||
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
|
||||
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
|
||||
await expect(pageResult).toHaveCSS("min-height", "32px")
|
||||
await expect(pageResult.locator("svg")).toHaveCount(1)
|
||||
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
|
||||
await view.search.fill("Agent")
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
|
||||
"AgentDesktop notifications",
|
||||
)
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
|
||||
"AgentSound effects",
|
||||
)
|
||||
await view.search.fill("zzzzzzzzzz")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("status")).toHaveText("No setting found")
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Escape")
|
||||
await expect(view.search).toHaveValue("")
|
||||
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of [
|
||||
{ tab: "Models", search: "Search models" },
|
||||
{ tab: "Shortcuts", search: "Search shortcuts" },
|
||||
{ tab: "Models", search: "Search models" },
|
||||
]) {
|
||||
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
|
||||
}
|
||||
await view.search.fill("shortcuts")
|
||||
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
|
||||
await result.click()
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
await page.route("**/api/project", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fulfill({ json: projectList(8) })
|
||||
})
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await page.reload()
|
||||
await requested
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
try {
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(search).toHaveCount(0)
|
||||
} finally {
|
||||
inventory.resolve()
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
await view.search.fill(en[entry.label as keyof typeof en])
|
||||
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
|
||||
await expect(result).toHaveCount(1)
|
||||
await result.click()
|
||||
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
|
||||
}
|
||||
})
|
||||
|
||||
test("qualified project results preserve query and selection on return", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("project name")
|
||||
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
|
||||
await view.search.fill("OpenCode")
|
||||
const project = view.results.getByRole("option")
|
||||
await expect(project).toHaveText("OOpenCodeProjects")
|
||||
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "14px")
|
||||
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
|
||||
await view.search.fill("OpenCode name")
|
||||
const name = view.results.getByRole("option")
|
||||
await expect(name).toHaveText("Project nameGeneral")
|
||||
await name.click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.search).toHaveValue("OpenCode name")
|
||||
await expect(name).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("returning from a project restores a scrolled result list", async ({ page }) => {
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({
|
||||
json: projectList(30),
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(30)
|
||||
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
|
||||
await project.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
|
||||
const scroll = await view.results.evaluate((list) => list.scrollTop)
|
||||
await project.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => view.results.evaluate((list) => list.scrollTop)).toBe(scroll)
|
||||
})
|
||||
|
||||
test("IME confirmation does not activate a search result", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(3)
|
||||
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Enter")
|
||||
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.settings.evaluate((root) => {
|
||||
root.setAttribute("data-search-flashes", "0")
|
||||
root.addEventListener("animationstart", (event) => {
|
||||
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
|
||||
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
|
||||
})
|
||||
})
|
||||
await view.search.fill("skills")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.settings.evaluate(async (root) => {
|
||||
await Promise.all(
|
||||
root
|
||||
.getAnimations({ subtree: true })
|
||||
.filter(
|
||||
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
|
||||
)
|
||||
.map((animation) => animation.finished),
|
||||
)
|
||||
})
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
for (const tab of ["MCPs", "Plugins", "Skills"]) {
|
||||
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
}
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
|
||||
await view.search.fill("about")
|
||||
await view.results.getByRole("option", { name: "About", exact: true }).click()
|
||||
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
|
||||
})
|
||||
|
||||
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
|
||||
const server = "http://127.0.0.1:4097"
|
||||
const remote = createMockServerHandler({
|
||||
...config,
|
||||
directory: "/remote/opencode",
|
||||
project: { ...config.project, canonical: "/remote/opencode" },
|
||||
})
|
||||
page.on("close", () => void remote.dispose())
|
||||
await installSseTransport(page, { server })
|
||||
await page.route(`${server}/api/**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
const response = await remote.handler(
|
||||
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
|
||||
)
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
(server) =>
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
|
||||
),
|
||||
server,
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("MCPs")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(2)
|
||||
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toHaveValue("MCPs")
|
||||
await view.search.fill("Build server models")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await view.search.fill("Build server OpenCode name")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test.describe(`search layout ${direction}`, () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
colorScheme: direction === "rtl" ? "dark" : "light",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
})
|
||||
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await page.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
const input = await view.search.boundingBox()
|
||||
const content = await view.settings.locator(".settings-content").boundingBox()
|
||||
await view.search.fill("e")
|
||||
await expect.poll(() => view.search.boundingBox()).toEqual(input)
|
||||
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
|
||||
await view.search.fill("terminal font")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.results).toBeHidden()
|
||||
await expect(view.search).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.results).toBeVisible()
|
||||
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
|
||||
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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<void>()
|
||||
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<void>()
|
||||
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<string>()
|
||||
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)
|
||||
})
|
||||
@@ -70,23 +70,7 @@ 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,
|
||||
|
||||
@@ -9,9 +9,6 @@ export interface MockServerConfig {
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
preferences?: Record<string, unknown>
|
||||
shells?: unknown[]
|
||||
websearchProviders?: unknown[]
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
@@ -188,14 +185,13 @@ export function createMockServerHandler(config: MockServerConfig) {
|
||||
const corsHeaders = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers": "*",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
}
|
||||
|
||||
function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, string>; nextCursor: number }) {
|
||||
const noContent = Effect.succeed(HttpApiSchema.NoContent.make())
|
||||
const delay = config.messageDelay === undefined ? Effect.void : Effect.sleep(Duration.millis(config.messageDelay))
|
||||
const preferences = { current: config.preferences ?? {} }
|
||||
return HttpApiBuilder.group(MockApi, "mock", (handlers) =>
|
||||
handlers
|
||||
.handleRaw("event", () => {
|
||||
@@ -273,29 +269,12 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
const project = config.project as typeof config.project & { canonical?: string; worktree?: string }
|
||||
return Effect.succeed([{ ...project, canonical: project.canonical ?? project.worktree ?? config.directory }])
|
||||
},
|
||||
projectUpdate: (ctx) => {
|
||||
const project = config.project as { canonical?: string }
|
||||
return Effect.succeed({
|
||||
...project,
|
||||
...ctx.payload,
|
||||
id: ctx.params.projectID,
|
||||
canonical: project.canonical ?? config.directory,
|
||||
})
|
||||
},
|
||||
projectCurrent: () =>
|
||||
Effect.succeed({
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
}),
|
||||
configPreferences: () => Effect.succeed(preferences.current),
|
||||
configUpdatePreferences: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
preferences.current = { ...preferences.current, ...ctx.payload }
|
||||
return preferences.current
|
||||
}),
|
||||
configShells: () => Effect.succeed(config.shells ?? []),
|
||||
websearchProviders: () => Effect.succeed({ location: location(config), data: config.websearchProviders ?? [] }),
|
||||
worktreeList: () =>
|
||||
Effect.succeed([
|
||||
{ directory: config.directory },
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { PlatformProvider, type Platform } from "../../src/runtime/platform/platform"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { useWslServers } from "../../src/servers/wsl/context"
|
||||
import type { WslServersEvent, WslServersPlatform, WslServersState } from "../../src/servers/wsl/types"
|
||||
|
||||
export function mount(input: { server: string; mode: "failed" | "stopped" | "ready" }) {
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: "/settings", replace: true, scroll: false })
|
||||
render(() => {
|
||||
const [store, setStore] = createStore<{ calls: string[]; state: WslServersState }>({
|
||||
calls: [],
|
||||
state: {
|
||||
runtime: { available: true, version: "2", error: null },
|
||||
installed: [],
|
||||
online: [],
|
||||
distroProbes: {},
|
||||
pendingRestart: false,
|
||||
job: null,
|
||||
servers: [
|
||||
{
|
||||
config: { id: "wsl:Ubuntu", distro: "Ubuntu" },
|
||||
runtime:
|
||||
input.mode === "ready"
|
||||
? { kind: "ready", url: input.server, password: null }
|
||||
: input.mode === "failed"
|
||||
? { kind: "failed", message: "WSL failed to start" }
|
||||
: { kind: "stopped" },
|
||||
},
|
||||
],
|
||||
opencodeChecks: {
|
||||
Ubuntu: {
|
||||
distro: "Ubuntu",
|
||||
resolvedPath: "/usr/bin/opencode",
|
||||
version: "old",
|
||||
expectedVersion: "current",
|
||||
matchesDesktop: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const publish = () =>
|
||||
listeners.forEach((listener) => listener({ type: "state", state: structuredClone(unwrap(store.state)) }))
|
||||
const unused = async () => {
|
||||
throw new Error("Unexpected fixture action")
|
||||
}
|
||||
const wsl: WslServersPlatform = {
|
||||
getState: async () => structuredClone(unwrap(store.state)),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
probeRuntime: unused,
|
||||
refreshDistros: unused,
|
||||
installWsl: unused,
|
||||
installDistro: unused,
|
||||
probeAddable: unused,
|
||||
openTerminal: unused,
|
||||
addServer: unused,
|
||||
async installOpencode(distro) {
|
||||
setStore("calls", (calls) => [...calls, `update:${distro}`])
|
||||
setStore("state", "opencodeChecks", distro, { version: "current", matchesDesktop: true })
|
||||
publish()
|
||||
},
|
||||
async startServer(id) {
|
||||
setStore("calls", (calls) => [...calls, `start:${id}`])
|
||||
setStore("state", "servers", (server) => server.config.id === id, "runtime", {
|
||||
kind: "ready",
|
||||
url: input.server,
|
||||
password: null,
|
||||
})
|
||||
publish()
|
||||
},
|
||||
async removeServer(id) {
|
||||
setStore("calls", (calls) => [...calls, `remove:${id}`])
|
||||
setStore("state", "servers", (servers) => servers.filter((server) => server.config.id !== id))
|
||||
publish()
|
||||
},
|
||||
}
|
||||
const platform: Platform = {
|
||||
platform: "desktop",
|
||||
os: "windows",
|
||||
windowID: "settings-wsl-test",
|
||||
openExternal: () => undefined,
|
||||
openDirectoryPickerDialog: async () => null,
|
||||
notify: async () => undefined,
|
||||
restart: unused,
|
||||
wslServers: wsl,
|
||||
}
|
||||
function Interface() {
|
||||
const wsl = useWslServers()
|
||||
const servers = createMemo<ServerConnection.Any[]>(() => [
|
||||
{ type: "sidecar", variant: "base", displayName: "Local Server", http: { url: input.server } },
|
||||
...(wsl.data?.servers ?? []).flatMap((item): ServerConnection.Any[] =>
|
||||
item.runtime.kind === "ready"
|
||||
? [
|
||||
{
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: item.config.distro,
|
||||
displayName: item.config.distro,
|
||||
http: { url: item.runtime.url },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
])
|
||||
return (
|
||||
<Show when={wsl.data}>
|
||||
<AppInterface
|
||||
servers={servers()}
|
||||
defaultServer={ServerConnection.Key.make("sidecar")}
|
||||
router={(props) => <MemoryRouter {...props} history={history} />}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
<output aria-label="WSL actions">{store.calls.join(",")}</output>
|
||||
<Interface />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module">
|
||||
import { mount } from "./settings-wsl.fixture.tsx"
|
||||
const query = new URLSearchParams(location.search)
|
||||
mount({ server: query.get("server"), mode: query.get("mode") })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,7 +40,6 @@ export default defineConfig({
|
||||
reuseExistingServer: !built,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_TEST_FIXTURES: "1",
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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"
|
||||
@@ -28,7 +27,6 @@ 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()
|
||||
@@ -133,9 +131,8 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
})
|
||||
},
|
||||
edit: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
settings.openProject({
|
||||
server: ServerConnection.key(conn),
|
||||
project: project.worktree,
|
||||
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
|
||||
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
|
||||
})
|
||||
},
|
||||
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createPromptProjectController } from "@/new-session/project/selector"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
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"
|
||||
@@ -17,19 +16,10 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
const settings = useSettings()
|
||||
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const tabs = useTabs()
|
||||
const servers = useSettingsServers()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
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,
|
||||
|
||||
@@ -149,7 +149,7 @@ export const DialogManageModels: Component = () => {
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
@@ -162,7 +162,7 @@ export const DialogManageModels: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList variant="catalog">
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
|
||||
@@ -162,12 +162,12 @@ const ModelList: Component<{
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<SettingsList variant="catalog">
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
|
||||
@@ -448,7 +448,6 @@ export const dict = {
|
||||
"dialog.server.menu.default": "Set as default",
|
||||
"dialog.server.menu.defaultRemove": "Remove default",
|
||||
"dialog.server.menu.delete": "Delete",
|
||||
"dialog.server.menu.remove": "Remove",
|
||||
"dialog.server.menu.hide": "Hide from project list",
|
||||
"dialog.server.menu.show": "Show in project list",
|
||||
"dialog.server.current": "Current Server",
|
||||
@@ -997,15 +996,6 @@ export const dict = {
|
||||
"settings.section.desktop": "Desktop",
|
||||
"settings.section.server": "Server",
|
||||
"settings.backToApp": "Back to app",
|
||||
"settings.backToSettings": "Back to settings",
|
||||
"settings.backToProjects": "Back to projects",
|
||||
"settings.search.placeholder": "Search settings",
|
||||
"settings.search.results": "Settings results",
|
||||
"settings.search.result": "{{title}}, {{scope}}, {{page}}",
|
||||
"settings.search.page": "{{title}}, {{scope}}",
|
||||
"settings.search.result.unscoped": "{{title}}, {{page}}",
|
||||
"settings.search.empty": "No setting found",
|
||||
"settings.search.refine": "Narrow your search to see more specific results.",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.preferences": "Preferences",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
@@ -1043,14 +1033,9 @@ export const dict = {
|
||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
||||
"settings.servers.description": "Manage server connections",
|
||||
"settings.server.description": "Manage this server’s connection and preferences",
|
||||
"settings.server.section.connection": "Connection",
|
||||
"settings.server.preferences.websearch.title": "Third-party search",
|
||||
"settings.server.preferences.websearch.description": "Select the search provider agents use to search the web",
|
||||
"settings.projects.title": "Projects",
|
||||
"settings.projects.description": "Manage project settings on this server",
|
||||
"settings.projects.empty": "No projects found",
|
||||
"settings.projects.search.placeholder": "Search projects",
|
||||
"settings.projects.server.all": "All servers",
|
||||
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||
"settings.extensions.description": "Manage extensions available on this server",
|
||||
@@ -1065,36 +1050,14 @@ export const dict = {
|
||||
"dialog.server.authenticate.title": "Authenticate",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.general.description": "Manage project name and appearance",
|
||||
"project.settings.name.title": "Project name",
|
||||
"project.settings.name.description": "The name shown for this project throughout OpenCode",
|
||||
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
|
||||
"project.settings.color.description": "Used for the project icon when no custom image is set",
|
||||
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
|
||||
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
|
||||
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"project.settings.scripts": "Scripts",
|
||||
"project.settings.scripts.description": "Configure scripts for this project",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
"project.settings.extensions.shared": "Shared with all projects",
|
||||
"project.settings.extensions.empty.mcps.title": "No MCPs yet",
|
||||
"project.settings.extensions.empty.mcps.description": "MCPs available to OpenCode will appear here",
|
||||
"project.settings.extensions.empty.plugins.title": "No plugins yet",
|
||||
"project.settings.extensions.empty.plugins.description": "Plugins available to OpenCode will appear here",
|
||||
"project.settings.extensions.empty.skills.title": "No skills yet",
|
||||
"project.settings.extensions.empty.skills.description": "Skills available to OpenCode will appear here",
|
||||
"project.settings.extensions.lsp.detected": "Detected language servers",
|
||||
"project.settings.extensions.lsp.description": "Auto-detected from file types",
|
||||
"project.settings.extensions.lsp.configured": "Configured language servers",
|
||||
"project.settings.extensions.lsp.status.enabled": "Enabled in config",
|
||||
"project.settings.extensions.lsp.status.disabled": "Disabled in config",
|
||||
"project.settings.extensions.lsp.empty.title": "No language servers configured",
|
||||
"project.settings.extensions.lsp.empty.description": "Language servers configured for this project will appear here",
|
||||
"project.settings.extensions.lsp.disabled.title": "Language servers disabled",
|
||||
"project.settings.extensions.lsp.disabled.description": "LSP is disabled in this project’s configuration",
|
||||
"project.settings.extensions.lsp.loadFailed": "Could not load language server configuration",
|
||||
"project.settings.extensions.lsp.retry": "Retry",
|
||||
"project.settings.extensions.setupRequired": "Setup required",
|
||||
|
||||
"settings.general.section.appearance": "Appearance",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServerHealth } from "@/runtime/server/health"
|
||||
@@ -26,8 +27,24 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
() => server.list,
|
||||
() => true,
|
||||
)
|
||||
const [store, setStore] = createStore({
|
||||
settings: {
|
||||
serverKey: undefined as ServerConnection.Key | undefined,
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const conn = settingsServer()
|
||||
const key = conn ? ServerConnection.key(conn) : undefined
|
||||
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
||||
})
|
||||
|
||||
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
|
||||
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
|
||||
|
||||
@@ -67,6 +84,17 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
list: () => server.list,
|
||||
health: serverHealth,
|
||||
},
|
||||
settings: {
|
||||
server: {
|
||||
get key() {
|
||||
return store.settings.serverKey
|
||||
},
|
||||
selected: settingsServer,
|
||||
set(key: ServerConnection.Key) {
|
||||
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
|
||||
},
|
||||
},
|
||||
},
|
||||
models,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
|
||||
@@ -25,15 +25,11 @@ type FormMode = "list" | "add" | "edit"
|
||||
export const DialogServer: Component<{
|
||||
mode: "add" | "edit"
|
||||
server?: ServerConnection.Http
|
||||
onSave?: (server: ServerConnection.Http) => void
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const form = createFormController({
|
||||
onSelect: (server) => {
|
||||
props.onSave?.(server)
|
||||
dialog.close()
|
||||
},
|
||||
onSelect: () => dialog.close(),
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
@@ -137,7 +133,7 @@ export const DialogServer: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function createFormController(options: { onSelect?: (server: ServerConnection.Http) => void } = {}) {
|
||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
const platform = usePlatform()
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
@@ -224,14 +220,13 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
if (original?.type === "http") {
|
||||
if (normalized === original.http.url) add(connection)
|
||||
if (normalized !== original.http.url) replace(ServerConnection.key(original), connection)
|
||||
options.onSelect?.(connection)
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
add(connection)
|
||||
options.onSelect?.(connection)
|
||||
options.onSelect?.()
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sortServerConnections } from "./controller"
|
||||
|
||||
const server = (url: string): ServerConnection.Http => ({ type: "http", http: { url } })
|
||||
|
||||
describe("sortServerConnections", () => {
|
||||
test("places the default first and preserves health and insertion ordering", () => {
|
||||
const first = server("http://first")
|
||||
const offline = server("http://offline")
|
||||
const preferred = server("http://preferred")
|
||||
const unknown = server("http://unknown")
|
||||
const result = sortServerConnections({
|
||||
servers: [first, offline, preferred, unknown],
|
||||
health: {
|
||||
[ServerConnection.key(first)]: { healthy: true },
|
||||
[ServerConnection.key(offline)]: { healthy: false },
|
||||
},
|
||||
defaultKey: ServerConnection.key(preferred),
|
||||
})
|
||||
|
||||
expect(result).toEqual([preferred, first, unknown, offline])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { type ServerHealth } from "@/runtime/server/health"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -47,27 +49,6 @@ function useDefaultServer() {
|
||||
}
|
||||
}
|
||||
|
||||
export function sortServerConnections(input: {
|
||||
servers: ServerConnection.Any[]
|
||||
health: Record<string, ServerHealth | undefined>
|
||||
defaultKey: ServerConnection.Key | null
|
||||
}) {
|
||||
const order = new Map(input.servers.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return input.servers.slice().sort((a, b) => {
|
||||
const preferred =
|
||||
Number(ServerConnection.key(b) === input.defaultKey) - Number(ServerConnection.key(a) === input.defaultKey)
|
||||
if (preferred !== 0) return preferred
|
||||
const health = rank(input.health[ServerConnection.key(a)]) - rank(input.health[ServerConnection.key(b)])
|
||||
if (health !== 0) return health
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServers()
|
||||
const ssh = useSsh()
|
||||
@@ -97,8 +78,8 @@ export function useServerActionsController() {
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return server.visible.length > 1 && !!conn && ServerConnection.builtin(conn)
|
||||
},
|
||||
isHidden: (key: ServerConnection.Key) => server.isHidden(key),
|
||||
setHidden: (key: ServerConnection.Key, hidden: boolean) => server.setHidden(key, hidden),
|
||||
isHidden: server.isHidden,
|
||||
setHidden: server.setHidden,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -108,16 +89,27 @@ export type ServerActionsController = ReturnType<typeof useServerActionsControll
|
||||
export function useServerCollectionController() {
|
||||
const server = useServers()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const actions = useServerActionsController()
|
||||
|
||||
const items = createMemo(() => server.list)
|
||||
const sorted = createMemo(() =>
|
||||
sortServerConnections({
|
||||
servers: items(),
|
||||
health: global.servers.health,
|
||||
defaultKey: actions.defaults.key(),
|
||||
}),
|
||||
)
|
||||
const sorted = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = raw
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
collection: {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
|
||||
edit: language.t("dialog.server.menu.edit"),
|
||||
default: language.t("dialog.server.menu.default"),
|
||||
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
|
||||
remove: language.t("dialog.server.menu.remove"),
|
||||
delete: language.t("dialog.server.menu.delete"),
|
||||
hide: language.t("dialog.server.menu.hide"),
|
||||
show: language.t("dialog.server.menu.show"),
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export const ServerRowMenuView: Component<{
|
||||
</Show>
|
||||
<Show when={props.canRemove}>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={props.onRemove}>{props.labels.remove}</Menu.Item>
|
||||
<Menu.Item onSelect={props.onRemove}>{props.labels.delete}</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
|
||||
@@ -11,16 +11,14 @@ import { Spinner } from "@opencode/ui/spinner"
|
||||
import { sshName } from "./name"
|
||||
import { isSshConnecting } from "./status"
|
||||
|
||||
export function SshServerSettings(props: { filter: string; id?: string; domain: ServerCollectionController }) {
|
||||
export function SshServerSettings(props: { filter: string; domain: ServerCollectionController }) {
|
||||
const ssh = useSsh()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<For
|
||||
each={ssh.servers.filter(
|
||||
(item) =>
|
||||
item.saved &&
|
||||
(!props.id || item.config.id === props.id) &&
|
||||
`${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
|
||||
item.saved && `${item.config.name} ${item.config.target}`.toLowerCase().includes(props.filter.toLowerCase()),
|
||||
)}
|
||||
>
|
||||
{(item) => {
|
||||
|
||||
@@ -16,14 +16,13 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { DialogAddWslServer } from "./dialog"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./model"
|
||||
import type { WslServerItem } from "./types"
|
||||
import { DialogSsh } from "../ssh/dialog"
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
|
||||
export function AddServerMenu(props: { onAddServer: () => void; compact?: boolean }) {
|
||||
export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
@@ -34,41 +33,15 @@ export function AddServerMenu(props: { onAddServer: () => void; compact?: boolea
|
||||
<Show
|
||||
when={platform.wslServers || platform.sshServers}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.compact}
|
||||
fallback={
|
||||
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="plus" />}
|
||||
aria-label={language.t("dialog.server.add.button")}
|
||||
onClick={props.onAddServer}
|
||||
/>
|
||||
</Show>
|
||||
<Button variant="ghost-muted" icon="plus" onClick={props.onAddServer}>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Show
|
||||
when={props.compact}
|
||||
fallback={
|
||||
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Menu.Trigger>
|
||||
}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="plus" />}
|
||||
aria-label={language.t("dialog.server.add.button")}
|
||||
/>
|
||||
</Show>
|
||||
<Menu.Trigger as={Button} variant="ghost-muted" icon="plus">
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.Item onSelect={props.onAddServer}>{language.t("dialog.server.add.button")}</Menu.Item>
|
||||
@@ -99,7 +72,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
servers: Accessor<readonly WslServerItem[]>
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
@@ -187,9 +160,7 @@ export function WslServerSettings(props: {
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={request.isPending} onSelect={() => remove(key)}>
|
||||
{language.t("dialog.server.menu.remove")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => remove(key)}>{language.t("dialog.server.menu.delete")}</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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"
|
||||
@@ -9,14 +10,12 @@ 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"
|
||||
@@ -43,9 +42,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,
|
||||
@@ -65,13 +64,11 @@ export function SessionProjectMenu(props: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const openProjectSettings = () => {
|
||||
const openProjectSettings = async () => {
|
||||
const current = props.project
|
||||
if (!current) return
|
||||
settingsSurface.openProject({
|
||||
server: ServerConnection.key(server.conn),
|
||||
project: current.worktree,
|
||||
})
|
||||
const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
|
||||
dialog.push(() => <DialogEditProject project={{ expanded: false, ...current }} server={server.conn} />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -194,7 +191,7 @@ export function SessionProjectMenu(props: {
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
|
||||
<Menu.Item disabled={!props.project} onSelect={() => void openProjectSettings()}>
|
||||
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
|
||||
{language.t("project.settings.title")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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?: SettingsRootTab) {
|
||||
export function useSettingsDialog(defaultValue?: string) {
|
||||
const settings = useSettingsSurface()
|
||||
return () => settings.open(defaultValue)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 {
|
||||
@@ -15,100 +14,32 @@ import {
|
||||
useSettings,
|
||||
} from "@/settings/model"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/shell/notifications/sound"
|
||||
import { createSoundPreviewController } from "./behavior"
|
||||
import { createSoundPreviewController, type ShellOption } 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 createServerPreferencesController(server: Accessor<ServerConnection.Any>) {
|
||||
const language = useLanguage()
|
||||
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
|
||||
const serverCtx = useServerCtx(server)
|
||||
const source = () => ServerConnection.key(server())
|
||||
const [preferences, preferencesActions] = createResource<ConfigPreferences, ServerConnection.Key>(
|
||||
source,
|
||||
() =>
|
||||
serverCtx()
|
||||
.sdk.api.config.preferences()
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const [shells] = createResource(
|
||||
source,
|
||||
() =>
|
||||
serverCtx()
|
||||
.sdk.api.config.shells()
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
async () => {
|
||||
// TODO: Dax is considering the V2 shell discovery and config update APIs.
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
const [providers] = createResource(
|
||||
source,
|
||||
() =>
|
||||
serverCtx()
|
||||
.sdk.api.websearch.providers()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const update = async (patch: ConfigUpdatePreferencesInput) => {
|
||||
const context = serverCtx()
|
||||
const previous = preferences.latest
|
||||
preferencesActions.mutate({
|
||||
...previous,
|
||||
...(patch.shell === undefined ? {} : { shell: patch.shell ?? undefined }),
|
||||
...(patch.websearch === undefined ? {} : { websearch: patch.websearch ?? undefined }),
|
||||
})
|
||||
await context.sdk.api.config
|
||||
.updatePreferences(patch)
|
||||
.then(preferencesActions.mutate)
|
||||
.catch((error: unknown) => {
|
||||
preferencesActions.mutate(previous)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const websearchOptions = createMemo(() => {
|
||||
const options = providers.latest.map((provider) => ({ value: provider.id, label: provider.name }))
|
||||
const selected = preferences.latest.websearch
|
||||
const configured = selected && selected.provider !== "random" ? selected.provider : undefined
|
||||
return [
|
||||
{ value: "random" as const, label: language.t("session.websearch.any") },
|
||||
...options,
|
||||
...(configured && !options.some((option) => option.value === configured)
|
||||
? [{ value: configured, label: configured }]
|
||||
: []),
|
||||
{ value: false as const, label: language.t("session.websearch.disable") },
|
||||
]
|
||||
})
|
||||
const websearchCurrent = createMemo(() => {
|
||||
const selection = preferences.latest.websearch
|
||||
const value = selection === false ? false : (selection?.provider ?? "random")
|
||||
return websearchOptions().find((option) => option.value === value) ?? websearchOptions()[0]
|
||||
})
|
||||
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
|
||||
|
||||
return {
|
||||
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 } })
|
||||
},
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -208,6 +139,6 @@ export function createSoundSettingsController() {
|
||||
}
|
||||
}
|
||||
|
||||
export type ShellSettingsController = ReturnType<typeof createServerPreferencesController>["shell"]
|
||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||
|
||||
@@ -21,10 +21,12 @@ 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 = {
|
||||
@@ -83,7 +85,6 @@ const WorkspaceDestinationSetting: Component = () => {
|
||||
description={language.t("settings.workspaces.default.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-workspace-destination"
|
||||
options={options()}
|
||||
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||
value={(option) => option.value}
|
||||
@@ -96,7 +97,7 @@ const WorkspaceDestinationSetting: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const options = createMemo(() =>
|
||||
createShellOptions({
|
||||
@@ -294,12 +295,15 @@ const LanguageSetting = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
export const SettingsGeneral: Component<{
|
||||
server?: ServerConnection.Any
|
||||
}> = (props) => {
|
||||
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(
|
||||
@@ -324,32 +328,10 @@ export const SettingsGeneral: Component = () => {
|
||||
<WorkspaceDestinationSetting />
|
||||
<AutoApprovePermissionsSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
>
|
||||
<div data-action="settings-show-custom-agents">
|
||||
<Switch
|
||||
checked={settings.general.showCustomAgents()}
|
||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.pinchZoom.title")}
|
||||
description={language.t("settings.general.row.pinchZoom.description")}
|
||||
>
|
||||
<div data-action="settings-pinch-zoom">
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("session.review.wrapLines")}
|
||||
description={language.t("settings.general.row.mobileDiffWrap.description")}
|
||||
@@ -388,6 +370,18 @@ export const SettingsGeneral: Component = () => {
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showSearch.title")}
|
||||
description={language.t("settings.general.row.showSearch.description")}
|
||||
>
|
||||
<div data-action="settings-show-search">
|
||||
<Switch
|
||||
checked={settings.general.showSearch()}
|
||||
onChange={(checked) => settings.general.setShowSearch(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showStatus.title")}
|
||||
description={language.t("settings.general.row.showStatus.description")}
|
||||
@@ -399,6 +393,18 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.showCustomAgents.title")}
|
||||
description={language.t("settings.general.row.showCustomAgents.description")}
|
||||
>
|
||||
<div data-action="settings-show-custom-agents">
|
||||
<Switch
|
||||
checked={settings.general.showCustomAgents()}
|
||||
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)
|
||||
@@ -468,13 +474,7 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button
|
||||
data-action="settings-check-updates"
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={!updater.action().run}
|
||||
onClick={() => updater.run()}
|
||||
>
|
||||
<Button size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
||||
{language.t(updater.action().label)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
@@ -482,6 +482,26 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
)
|
||||
|
||||
// We can probably remove this, right?
|
||||
const DisplaySection = () => (
|
||||
<Show when={desktop()}>
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.display")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.pinchZoom.title")}
|
||||
description={language.t("settings.general.row.pinchZoom.description")}
|
||||
>
|
||||
<div data-action="settings-pinch-zoom">
|
||||
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
@@ -494,7 +514,7 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<div class="settings-tab-body">
|
||||
<GeneralSection />
|
||||
|
||||
<section class="settings-section" aria-label={language.t("settings.timeline.title")}>
|
||||
@@ -513,6 +533,8 @@ export const SettingsGeneral: Component = () => {
|
||||
<UpdatesSection />
|
||||
</Show>
|
||||
|
||||
<DisplaySection />
|
||||
|
||||
<AdvancedSection />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
|
||||
import { For, Show, createMemo, lazy, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -342,7 +342,7 @@ export function createKeybindSettingsController(
|
||||
}
|
||||
}
|
||||
|
||||
export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean }) {
|
||||
export function SettingsKeybinds() {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const controller = createKeybindSettingsController({
|
||||
@@ -352,8 +352,6 @@ export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean
|
||||
|
||||
return (
|
||||
<SettingsKeybindsView
|
||||
visible={props.active}
|
||||
autofocus={props.autofocus}
|
||||
groups={controller.catalog.groups}
|
||||
filtered={controller.catalog.filtered}
|
||||
title={controller.catalog.title}
|
||||
@@ -367,8 +365,6 @@ export function SettingsKeybinds(props: { active?: boolean; autofocus?: boolean
|
||||
}
|
||||
|
||||
function SettingsKeybindsView(props: {
|
||||
visible?: boolean
|
||||
autofocus?: boolean
|
||||
groups: KeybindGroup[]
|
||||
filtered: (query: string) => Map<KeybindGroup, string[]>
|
||||
title: (id: string) => string
|
||||
@@ -379,20 +375,6 @@ function SettingsKeybindsView(props: {
|
||||
onReset: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.visible ?? true,
|
||||
(visible) => {
|
||||
if (!visible) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.visible !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const filtered = createMemo(() => props.filtered(store.filter))
|
||||
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
|
||||
@@ -411,7 +393,6 @@ function SettingsKeybindsView(props: {
|
||||
</div>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
@@ -436,7 +417,7 @@ function SettingsKeybindsView(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-shortcuts settings-section-stack">
|
||||
<div class="settings-shortcuts flex flex-col gap-8">
|
||||
<For each={props.groups}>
|
||||
{(group) => (
|
||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Component, JSX } from "solid-js"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsList: Component<{ children: JSX.Element; variant?: "catalog" }> = (props) => {
|
||||
return (
|
||||
<div data-component="settings-list" data-variant={props.variant}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||
return <div data-component="settings-list">{props.children}</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Switch } from "@opencode/ui/switch"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { type Component, createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -24,24 +25,10 @@ export const ModelProvidersSchema = Schema.Struct({
|
||||
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
|
||||
})
|
||||
|
||||
export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }> = (props) => {
|
||||
export const SettingsModels: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
let search: HTMLInputElement | undefined
|
||||
createEffect(
|
||||
on(
|
||||
() => props.active ?? true,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
)
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
|
||||
ModelProvidersSchema,
|
||||
@@ -78,10 +65,10 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
<h2 class="settings-tab-title">{language.t("settings.models.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={list.filter()}
|
||||
@@ -173,12 +160,12 @@ export const SettingsModels: Component<{ active?: boolean; autofocus?: boolean }
|
||||
height={PROVIDER_ICON_SIZE}
|
||||
class="settings-models-provider-icon shrink-0"
|
||||
/>
|
||||
<span class="settings-models-group-title">{group.items[0].provider.name}</span>
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList variant="catalog">
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => {
|
||||
const key = { providerID: item.provider.id, modelID: item.id }
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { SettingsSearch } from "./search"
|
||||
import "./search.css"
|
||||
|
||||
export type SettingsNavItem = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ComponentProps<typeof Icon>["name"]
|
||||
disabled?: boolean
|
||||
onPrefetch?: () => void
|
||||
}
|
||||
|
||||
export type SettingsNavGroup = {
|
||||
label?: string
|
||||
action?: JSX.Element
|
||||
items: readonly SettingsNavItem[]
|
||||
}
|
||||
|
||||
export function SettingsNavigation(props: {
|
||||
value: string
|
||||
groups: readonly SettingsNavGroup[]
|
||||
backLabel: string
|
||||
onBack: () => void
|
||||
onChange: (value: string) => void
|
||||
mobileAction?: JSX.Element
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const surface = useSettingsSurface()
|
||||
const searchable = () => surface.view().type === "root"
|
||||
const language = useLanguage()
|
||||
const back = () => {
|
||||
if (!searchable() && surface.search.back()) return
|
||||
props.onBack()
|
||||
}
|
||||
const backLabel = () =>
|
||||
!searchable() && surface.search.state.selected ? language.t("settings.backToSettings") : props.backLabel
|
||||
const current = () => props.groups.flatMap((group) => group.items).find((item) => item.value === props.value)
|
||||
const change = (value: string) => {
|
||||
surface.search.clear()
|
||||
props.onChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" variant="settings" value={props.value} onChange={change} class="settings">
|
||||
<div class="settings-mobile-nav">
|
||||
<button type="button" class="settings-back" onClick={back}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{backLabel()}</span>
|
||||
</button>
|
||||
<div class="settings-mobile-actions">
|
||||
{props.mobileAction}
|
||||
<Menu placement="bottom-end" gutter={8}>
|
||||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>{current()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.RadioGroup value={props.value} onChange={change}>
|
||||
<For each={props.groups}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<Menu.RadioItem
|
||||
value={item.value}
|
||||
disabled={item.disabled}
|
||||
closeOnSelect
|
||||
onPointerEnter={(event: PointerEvent) => {
|
||||
if (item.disabled || event.pointerType === "touch") return
|
||||
item.onPrefetch?.()
|
||||
}}
|
||||
onFocus={() => !item.disabled && item.onPrefetch?.()}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="settings-sidebar" data-searchable={searchable()}>
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={back}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{backLabel()}</span>
|
||||
</button>
|
||||
<Show when={searchable()}>
|
||||
<SettingsSearch />
|
||||
</Show>
|
||||
<Show when={!searchable() || !surface.search.state.query.trim()}>
|
||||
<Tabs.List class="settings-nav-groups">
|
||||
<For each={props.groups}>
|
||||
{(group) => (
|
||||
<div class="settings-nav-group">
|
||||
<Show when={group.label || group.action}>
|
||||
<div class="settings-nav-group-header" data-component="settings-nav-group-header">
|
||||
<span>{group.label}</span>
|
||||
{group.action}
|
||||
</div>
|
||||
</Show>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<Tabs.Trigger
|
||||
value={item.value}
|
||||
disabled={item.disabled}
|
||||
onPointerEnter={(event: PointerEvent) => {
|
||||
if (item.disabled || event.pointerType === "touch") return
|
||||
item.onPrefetch?.()
|
||||
}}
|
||||
onFocus={() => !item.disabled && item.onPrefetch?.()}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
{item.label}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="settings-content">{props.children}</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const SettingsNotifications: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
<SettingsList>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { IconProps } from "@opencode/ui/icon"
|
||||
import type { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { SettingsRootTab } from "./surface"
|
||||
|
||||
export const pageIcons = {
|
||||
general: "sliders",
|
||||
appearance: "appearance",
|
||||
notifications: "notifications",
|
||||
shortcuts: "keyboard",
|
||||
projects: "folder",
|
||||
workspaces: "outline-worktree",
|
||||
providers: "providers",
|
||||
models: "models",
|
||||
extensions: "extensions",
|
||||
servers: "server",
|
||||
experimental: "flask",
|
||||
about: "info",
|
||||
} as const satisfies Record<SettingsRootTab, IconProps["name"]>
|
||||
|
||||
export const pageLabels = {
|
||||
general: "settings.tab.preferences",
|
||||
appearance: "settings.general.section.appearance",
|
||||
notifications: "settings.tab.notifications",
|
||||
shortcuts: "settings.shortcuts.title",
|
||||
projects: "settings.tab.projects",
|
||||
workspaces: "settings.tab.workspaces",
|
||||
providers: "settings.providers.title",
|
||||
models: "settings.models.title",
|
||||
extensions: "settings.tab.extensions",
|
||||
servers: "settings.section.server",
|
||||
experimental: "settings.tab.experimental",
|
||||
about: "settings.tab.about",
|
||||
} as const satisfies Record<SettingsRootTab, Parameters<ReturnType<typeof useLanguage>["t"]>[0]>
|
||||
@@ -8,8 +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 { SettingsList } from "@/settings/list"
|
||||
import type { SettingsView } from "@/settings/surface"
|
||||
import { InlineServerSelect } from "@/settings/server-select"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
interface McpRowItem {
|
||||
@@ -21,10 +20,7 @@ interface PluginRowItem {
|
||||
name: string
|
||||
}
|
||||
|
||||
export const SettingsExtensions: Component<{
|
||||
subtab?: SettingsView["subtab"]
|
||||
onSubtab: (value: SettingsView["subtab"]) => void
|
||||
}> = (props) => {
|
||||
export const SettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
@@ -67,18 +63,12 @@ export const SettingsExtensions: Component<{
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.subtab ?? "mcps"}
|
||||
onChange={(value) => {
|
||||
if (value === "mcps" || value === "plugins" || value === "skills") props.onSubtab(value)
|
||||
}}
|
||||
class="settings-extensions-tabs settings-subtabs"
|
||||
>
|
||||
<Tabs variant="pill" defaultValue="mcps" class="settings-extensions-tabs">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
|
||||
@@ -88,20 +78,18 @@ export const SettingsExtensions: Component<{
|
||||
<Tabs.Content value="mcps">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">
|
||||
{language.t("settings.extensions.manageConfig")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<SettingsList variant="catalog">
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={mcps()}>
|
||||
{(item) => (
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="settings-extension-name truncate">{item.name}</span>
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||
</div>
|
||||
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
||||
{item.name}
|
||||
@@ -109,57 +97,58 @@ export const SettingsExtensions: Component<{
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">
|
||||
{language.t("settings.extensions.manageConfig")}
|
||||
</span>
|
||||
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<SettingsList variant="catalog">
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="settings-extension-name truncate">{plugin.name}</span>
|
||||
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="skills">
|
||||
<div class="settings-section">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="settings-extension-heading text-13-medium">
|
||||
<span class="text-13-medium text-v2-text-text-base">
|
||||
{language.t("settings.extensions.availableAll")}
|
||||
</span>
|
||||
<ExternalLink class="settings-extension-link text-13-regular" href="https://opencode.ai/docs/skills/">
|
||||
<ExternalLink
|
||||
class="text-13-regular text-v2-text-accent hover:underline"
|
||||
href="https://opencode.ai/docs/skills/"
|
||||
>
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
<SettingsList variant="catalog">
|
||||
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<div class="settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||
<span class="settings-extension-name truncate">{skill.name}</span>
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
|
||||
@@ -9,6 +9,8 @@ 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"
|
||||
|
||||
@@ -42,7 +44,11 @@ export const SettingsProviders: Component<{
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
||||
void dialog.show(() => (
|
||||
<SettingsServerScope directory={props.directory}>
|
||||
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||
</SettingsServerScope>
|
||||
))
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
@@ -128,13 +134,14 @@ export const SettingsProviders: Component<{
|
||||
<h2 class="settings-tab-title">{language.t("settings.providers.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned settings-providers">
|
||||
<div class="settings-tab-body settings-providers">
|
||||
<div class="settings-section" data-component="connected-providers-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.providers.section.connected")}</h3>
|
||||
<SettingsList variant="catalog">
|
||||
<SettingsList>
|
||||
<Show
|
||||
when={connected().length > 0}
|
||||
fallback={<div class="settings-provider-empty">{language.t("settings.providers.connected.empty")}</div>}
|
||||
@@ -175,7 +182,7 @@ export const SettingsProviders: Component<{
|
||||
|
||||
<div class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.providers.section.popular")}</h3>
|
||||
<SettingsList variant="catalog">
|
||||
<SettingsList>
|
||||
<For each={popular()}>
|
||||
{(item) => (
|
||||
<div class="settings-provider-row">
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import type { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { SettingsProjectTab, SettingsRootTab, SettingsServerTab } from "./surface"
|
||||
|
||||
type Label = Parameters<ReturnType<typeof useLanguage>["t"]>[0]
|
||||
|
||||
// Metadata only: search must not mount settings pages or fetch their catalogs.
|
||||
type Entry<Tab> = {
|
||||
tab: Tab
|
||||
label: Label
|
||||
target?: string
|
||||
keywords?: string
|
||||
description?: Label
|
||||
section?: Label
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
available?: "desktop" | "browser" | "dev" | "mobile-dev"
|
||||
}
|
||||
|
||||
export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
{ tab: "general", label: "settings.tab.preferences" },
|
||||
{ tab: "appearance", label: "settings.general.section.appearance" },
|
||||
{ tab: "notifications", label: "settings.tab.notifications" },
|
||||
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
|
||||
{ tab: "experimental", label: "settings.tab.experimental" },
|
||||
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
|
||||
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.workspaces.default.title",
|
||||
target: "settings-workspace-destination",
|
||||
description: "settings.workspaces.default.description",
|
||||
keywords: "worktree workspace default destination",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "command.permissions.autoaccept.enable",
|
||||
target: "settings-auto-accept-permissions",
|
||||
keywords: "permissions approve allow auto accept",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.terminalPlacement.title",
|
||||
target: "settings-terminal-placement",
|
||||
keywords: "terminal side bottom position",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.followUpBehavior.title",
|
||||
target: "settings-follow-up-behavior",
|
||||
keywords: "queue steer follow up",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.pinchZoom.title",
|
||||
target: "settings-pinch-zoom",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "session.review.wrapLines",
|
||||
target: "settings-mobile-diff-wrap",
|
||||
keywords: "diff wrap lines",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.mobileTitlebarBottom.title",
|
||||
target: "settings-mobile-titlebar-bottom",
|
||||
available: "mobile-dev",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.timeline.detail",
|
||||
target: "settings-timeline-detail",
|
||||
section: "settings.timeline.title",
|
||||
keywords: "thinking reasoning tools timeline summary detailed",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.releaseNotes.title",
|
||||
target: "settings-release-notes",
|
||||
section: "settings.general.section.updates",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.updates.row.check.title",
|
||||
target: "settings-check-updates",
|
||||
section: "settings.general.section.updates",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.showStatus.title",
|
||||
target: "settings-show-status",
|
||||
section: "settings.general.section.advanced",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.showCustomAgents.title",
|
||||
target: "settings-show-custom-agents",
|
||||
section: "settings.general.section.general",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.colorScheme.title",
|
||||
target: "settings-color-scheme",
|
||||
keywords: "dark mode light mode system",
|
||||
},
|
||||
{ tab: "appearance", label: "settings.general.row.theme.title", target: "settings-theme" },
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.uiFont.title",
|
||||
target: "settings-ui-font",
|
||||
keywords: "interface typeface",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.font.title",
|
||||
target: "settings-code-font",
|
||||
keywords: "code typeface",
|
||||
},
|
||||
{
|
||||
tab: "appearance",
|
||||
label: "settings.general.row.terminalFont.title",
|
||||
target: "settings-terminal-font",
|
||||
keywords: "terminal typeface",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.agent.title",
|
||||
target: "settings-notifications-agent",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.agent.description",
|
||||
keywords: "desktop notifications agent",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.permissions.title",
|
||||
target: "settings-notifications-permissions",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.permissions.description",
|
||||
keywords: "desktop notifications permissions",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.notifications.errors.title",
|
||||
target: "settings-notifications-errors",
|
||||
section: "settings.general.section.notifications",
|
||||
description: "settings.general.notifications.errors.description",
|
||||
keywords: "desktop notifications errors",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.agent.title",
|
||||
target: "settings-sounds-agent",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.agent.description",
|
||||
keywords: "sound audio agent",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.permissions.title",
|
||||
target: "settings-sounds-permissions",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.permissions.description",
|
||||
keywords: "sound audio permissions",
|
||||
},
|
||||
{
|
||||
tab: "notifications",
|
||||
label: "settings.general.sounds.errors.title",
|
||||
target: "settings-sounds-errors",
|
||||
section: "settings.general.section.sounds",
|
||||
description: "settings.general.sounds.errors.description",
|
||||
keywords: "sound audio errors",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.general.row.browserPane.title",
|
||||
target: "settings-experimental-browser",
|
||||
available: "browser",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.appearance.row.tabs.title",
|
||||
target: "settings-tab-layout",
|
||||
keywords: "vertical horizontal tabs",
|
||||
},
|
||||
{ tab: "experimental", label: "settings.appearance.row.projectName.title", target: "settings-show-project-name" },
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.general.row.showProjectIcon.title",
|
||||
target: "settings-show-project-icon",
|
||||
available: "dev",
|
||||
},
|
||||
]
|
||||
|
||||
export const serverSettings: Entry<SettingsServerTab>[] = [
|
||||
{ tab: "projects", label: "settings.tab.projects" },
|
||||
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
|
||||
{ tab: "providers", label: "settings.providers.title", keywords: "connect api key credentials" },
|
||||
{ tab: "models", label: "settings.models.title", keywords: "model picker visibility" },
|
||||
{ tab: "extensions", label: "settings.tab.extensions" },
|
||||
{
|
||||
tab: "extensions",
|
||||
subtab: "mcps",
|
||||
label: "settings.extensions.tab.mcps",
|
||||
keywords: "model context protocol tools",
|
||||
},
|
||||
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
|
||||
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.general.row.shell.title",
|
||||
target: "settings-shell",
|
||||
description: "settings.general.row.shell.description",
|
||||
keywords: "bash zsh powershell",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
label: "settings.server.preferences.websearch.title",
|
||||
target: "settings-websearch",
|
||||
description: "settings.server.preferences.websearch.description",
|
||||
keywords: "web search provider",
|
||||
},
|
||||
]
|
||||
|
||||
export const projectSettings: Entry<SettingsProjectTab>[] = [
|
||||
{ tab: "general", label: "project.settings.name.title", target: "settings-project-name", keywords: "rename" },
|
||||
{ tab: "general", label: "dialog.project.edit.icon", target: "settings-project-icon" },
|
||||
{ tab: "general", label: "dialog.project.edit.color", target: "settings-project-color" },
|
||||
{ tab: "workspaces", label: "settings.tab.workspaces", keywords: "workspaces disk usage cleanup delete" },
|
||||
{ tab: "extensions", label: "settings.tab.extensions" },
|
||||
{
|
||||
tab: "extensions",
|
||||
subtab: "mcps",
|
||||
label: "settings.extensions.tab.mcps",
|
||||
keywords: "model context protocol tools",
|
||||
},
|
||||
{ tab: "extensions", subtab: "plugins", label: "status.popover.tab.plugins" },
|
||||
{ tab: "extensions", subtab: "skills", label: "settings.extensions.tab.skills" },
|
||||
{ tab: "extensions", subtab: "lsps", label: "project.settings.extensions.tab.lsps", keywords: "language servers" },
|
||||
]
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { clientSettings, projectSettings, serverSettings } from "./search-catalog"
|
||||
import { pageLabels } from "./pages"
|
||||
import type { SettingsSearchResult } from "./search-results"
|
||||
import type { SettingsServerTab, SettingsView } from "./surface"
|
||||
|
||||
export type SettingsSearchServer = {
|
||||
key: string
|
||||
name: string
|
||||
connected: boolean
|
||||
projects: readonly LocalProject[]
|
||||
}
|
||||
|
||||
export function settingsSearchIndex(input: {
|
||||
servers: readonly SettingsSearchServer[]
|
||||
desktop: boolean
|
||||
browser: boolean
|
||||
dev: boolean
|
||||
mobile: boolean
|
||||
translate: ReturnType<typeof useLanguage>["t"]
|
||||
}) {
|
||||
const items: SettingsSearchResult[] = []
|
||||
const add = (
|
||||
entry: (typeof clientSettings)[number],
|
||||
view: SettingsView,
|
||||
owner: string,
|
||||
server?: string,
|
||||
project?: string,
|
||||
projectName?: string,
|
||||
) => {
|
||||
const page =
|
||||
!server && view.tab === "general" && view.target
|
||||
? `${input.translate(pageLabels.general)} / ${input.translate(entry.section ?? "settings.general.section.general")}`
|
||||
: input.translate(
|
||||
entry.section ??
|
||||
(view.type !== "root" && view.tab === "general"
|
||||
? "settings.general.section.general"
|
||||
: pageLabels[view.tab]),
|
||||
)
|
||||
items.push({
|
||||
id: JSON.stringify([server, project, view.tab, view.target, view.subtab, entry.label]),
|
||||
title: input.translate(entry.label),
|
||||
description: entry.description ? input.translate(entry.description) : "",
|
||||
keywords: entry.keywords ?? "",
|
||||
owner,
|
||||
page,
|
||||
server,
|
||||
project,
|
||||
projectName,
|
||||
topLevel: !view.target && !view.subtab && !project,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
||||
clientSettings.forEach((entry) => {
|
||||
if (entry.available === "desktop" && !input.desktop) return
|
||||
if (entry.available === "browser" && !input.browser) return
|
||||
if ((entry.available === "dev" || entry.available === "mobile-dev") && !input.dev) return
|
||||
if (entry.available === "mobile-dev" && !input.mobile) return
|
||||
add(entry, { type: "root", tab: entry.tab, target: entry.target }, "")
|
||||
})
|
||||
input.servers.forEach((server) => {
|
||||
const view = (tab: SettingsServerTab, target?: string, subtab?: SettingsView["subtab"]): SettingsView => {
|
||||
if (input.servers.length === 1) return { type: "root", tab: tab === "general" ? "servers" : tab, target, subtab }
|
||||
return { type: "server", server: server.key, tab, target, subtab }
|
||||
}
|
||||
items.push({
|
||||
id: `server:${server.key}`,
|
||||
entity: true,
|
||||
title: server.name,
|
||||
description: "",
|
||||
keywords: "",
|
||||
owner: input.translate("status.popover.tab.servers"),
|
||||
page: input.translate("settings.server.section.connection"),
|
||||
server: server.key,
|
||||
view: view("general"),
|
||||
})
|
||||
if (!server.connected) return
|
||||
serverSettings.forEach((entry) => add(entry, view(entry.tab, entry.target, entry.subtab), server.name, server.key))
|
||||
server.projects.forEach((project) => {
|
||||
const destination: SettingsView = {
|
||||
type: "project",
|
||||
server: server.key,
|
||||
project: project.worktree,
|
||||
tab: "general",
|
||||
parent: input.servers.length > 1 ? "server" : "root",
|
||||
}
|
||||
const name = displayName(project)
|
||||
items.push({
|
||||
id: `project:${server.key}:${project.worktree}`,
|
||||
entity: true,
|
||||
title: name,
|
||||
description: "",
|
||||
keywords: "",
|
||||
owner: server.name,
|
||||
page: input.translate("settings.tab.projects"),
|
||||
server: server.key,
|
||||
project: project.worktree,
|
||||
projectInfo: project,
|
||||
view: destination,
|
||||
})
|
||||
projectSettings.forEach((entry) => {
|
||||
if (entry.target === "settings-project-color" && project.icon?.override) return
|
||||
add(
|
||||
entry,
|
||||
{ ...destination, tab: entry.tab, target: entry.target, subtab: entry.subtab },
|
||||
`${server.name} · ${name}`,
|
||||
server.key,
|
||||
project.worktree,
|
||||
name,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
return items
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { dict } from "@opencode/ui/i18n/en"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import { settingsSearchIndex, type SettingsSearchServer } from "./search-index"
|
||||
import { rankSettings } from "./search-results"
|
||||
import type { SettingsView } from "./surface"
|
||||
|
||||
const strings: Record<string, string> = { ...dict, ...en }
|
||||
const project = { id: "proj_opencode", name: "OpenCode", worktree: "/projects/opencode", expanded: false }
|
||||
const servers: SettingsSearchServer[] = [
|
||||
{ key: "local", name: "Local server", connected: true, projects: [project] },
|
||||
{ key: "remote", name: "Build server", connected: true, projects: [project] },
|
||||
]
|
||||
const root: SettingsView = { type: "root", tab: "general" }
|
||||
|
||||
function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
|
||||
return settingsSearchIndex({
|
||||
servers,
|
||||
desktop: false,
|
||||
browser: false,
|
||||
dev: false,
|
||||
mobile: false,
|
||||
translate: (key) => strings[key],
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
describe("settings search index", () => {
|
||||
test("uses concrete server identity and adapts single-server destinations", () => {
|
||||
const multi = rankSettings("models", index(), root).filter((item) => item.topLevel)
|
||||
expect(multi.map((item) => item.view)).toEqual([
|
||||
{ type: "server", server: "local", tab: "models", target: undefined, subtab: undefined },
|
||||
{ type: "server", server: "remote", tab: "models", target: undefined, subtab: undefined },
|
||||
])
|
||||
const single = rankSettings("models", index({ servers: [servers[0]] }), root).find((item) => item.topLevel)!
|
||||
expect(single.server).toBe("local")
|
||||
expect(single.view).toEqual({ type: "root", tab: "models", target: undefined, subtab: undefined })
|
||||
})
|
||||
|
||||
test("keeps unavailable servers discoverable without advertising unloaded settings", () => {
|
||||
const items = index({ servers: [{ ...servers[0], connected: false }] })
|
||||
expect(items.filter((item) => item.server).map((item) => item.id)).toEqual(["server:local"])
|
||||
expect(rankSettings("Local server", items, root)[0].view).toEqual({
|
||||
type: "root",
|
||||
tab: "servers",
|
||||
target: undefined,
|
||||
subtab: undefined,
|
||||
})
|
||||
expect(rankSettings("font", items, root)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("only advertises settings supported by this platform and channel", () => {
|
||||
const targets = (input: Parameters<typeof index>[0]) => index(input).map((item) => item.view.target)
|
||||
expect(targets({})).not.toContain("settings-pinch-zoom")
|
||||
expect(targets({})).not.toContain("settings-experimental-browser")
|
||||
expect(targets({})).not.toContain("settings-show-project-icon")
|
||||
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
|
||||
expect(targets({ browser: true })).toContain("settings-experimental-browser")
|
||||
expect(targets({ dev: true })).toContain("settings-show-project-icon")
|
||||
expect(targets({ dev: true })).not.toContain("settings-mobile-titlebar-bottom")
|
||||
expect(targets({ dev: true, mobile: true })).toContain("settings-mobile-titlebar-bottom")
|
||||
})
|
||||
|
||||
test("uses section labels and stable identities independent of translated text", () => {
|
||||
const items = index()
|
||||
expect(new Set(items.map((item) => item.id)).size).toBe(items.length)
|
||||
expect(index({ translate: (key) => `translated ${strings[key]}` }).map((item) => item.id)).toEqual(
|
||||
items.map((item) => item.id),
|
||||
)
|
||||
expect(
|
||||
rankSettings("agent", items, root)
|
||||
.filter((item) => item.title === "Agent")
|
||||
.map((item) => item.page),
|
||||
).toEqual(["Desktop notifications", "Sound effects"])
|
||||
expect(rankSettings("terminal placement", items, root)[0].page).toBe("Preferences / General")
|
||||
expect(items.some((item) => item.view.target === "settings-project-startup")).toBe(false)
|
||||
})
|
||||
|
||||
test("retains project avatars and excludes color when a custom icon owns its appearance", () => {
|
||||
const withIcon = { ...project, icon: { color: "orange", override: "data:image/png;base64,example" } }
|
||||
const items = index({ servers: [{ ...servers[0], projects: [withIcon] }] })
|
||||
expect(rankSettings("opencode", items, root)[0].projectInfo).toEqual(withIcon)
|
||||
expect(rankSettings("opencode color", items, root)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("settings search ranking", () => {
|
||||
test("prioritizes top-level pages and omits generic project-setting copies", () => {
|
||||
expect(rankSettings("work", index({ servers: [servers[0]] }), root).map((item) => item.title)).toEqual([
|
||||
"Worktrees",
|
||||
"Default environment",
|
||||
])
|
||||
expect(rankSettings("mcps", index(), root).map((item) => item.view.type)).toEqual(["server", "server"])
|
||||
expect(rankSettings("project name", index(), root).map((item) => item.title)).toEqual(["Show project names"])
|
||||
expect(rankSettings("startup", index(), root)).toEqual([])
|
||||
})
|
||||
|
||||
test("qualifies project settings by project name and supports explicit server names", () => {
|
||||
const matches = rankSettings("opencode name", index(), root)
|
||||
expect(matches.map((item) => item.view.target)).toEqual(["settings-project-name", "settings-project-name"])
|
||||
expect(rankSettings("Build server opencode name", index(), root).map((item) => item.server)).toEqual(["remote"])
|
||||
expect(rankSettings("opencode skills", index(), root).map((item) => item.view.subtab)).toEqual(["skills", "skills"])
|
||||
})
|
||||
|
||||
test("project names alone find projects, including names that are setting labels", () => {
|
||||
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "Skills" }] }] })
|
||||
expect(
|
||||
rankSettings("skills", items, root)
|
||||
.filter((item) => item.project)
|
||||
.map((item) => item.entity),
|
||||
).toEqual([true])
|
||||
expect(rankSettings("opencode", index(), root).map((item) => item.entity)).toEqual([true, true])
|
||||
})
|
||||
|
||||
test("normalizes whitespace, case, and Unicode in qualified names", () => {
|
||||
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "Open Code" }] }] })
|
||||
expect(rankSettings(" OPEN CODE name ", items, root).map((item) => item.view.target)).toEqual([
|
||||
"settings-project-name",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not search paths or use the Projects category to match unrelated names", () => {
|
||||
const items = index({ servers: [{ ...servers[0], projects: [{ ...project, name: "codename" }] }] })
|
||||
expect(rankSettings("project name", items, root).some((item) => item.project)).toBe(false)
|
||||
expect(rankSettings("/projects/opencode", items, root)).toEqual([])
|
||||
})
|
||||
|
||||
test("ranks equivalent matches by origin without merging different servers", () => {
|
||||
const origin: SettingsView = {
|
||||
type: "project",
|
||||
server: "remote",
|
||||
project: project.worktree,
|
||||
tab: "general",
|
||||
parent: "server",
|
||||
}
|
||||
expect(rankSettings("opencode name", index(), origin).map((item) => item.server)).toEqual(["remote", "local"])
|
||||
})
|
||||
|
||||
test("supports synonyms and fuzzy title matches while rejecting unrelated queries", () => {
|
||||
expect(rankSettings("dark mode", index(), root)[0].view.target).toBe("settings-color-scheme")
|
||||
expect(rankSettings("termfont", index(), root)[0].title).toBe("Terminal Font")
|
||||
expect(rankSettings(" ", index(), root)).toEqual([])
|
||||
expect(rankSettings("zzzzzzzzz", index(), root)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { SettingsView } from "./surface"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
|
||||
export type SettingsSearchResult = {
|
||||
id: string
|
||||
title: string
|
||||
keywords: string
|
||||
description: string
|
||||
owner: string
|
||||
page: string
|
||||
server?: string
|
||||
project?: string
|
||||
projectName?: string
|
||||
projectInfo?: LocalProject
|
||||
entity?: boolean
|
||||
topLevel?: boolean
|
||||
view: SettingsView
|
||||
}
|
||||
|
||||
export function rankSettings(query: string, items: SettingsSearchResult[], origin: SettingsView) {
|
||||
const value = normalize(query)
|
||||
if (!value) return []
|
||||
return items
|
||||
.flatMap((item) => {
|
||||
const name = item.projectName ? normalize(item.projectName) : undefined
|
||||
if (name && !` ${value} `.includes(` ${name} `)) return []
|
||||
const query = name ? normalize(` ${value} `.replace(` ${name} `, " ")) : value
|
||||
if (!query) return []
|
||||
const tokens = query.split(" ")
|
||||
const title = normalize(item.title)
|
||||
const primary = normalize(`${title} ${item.keywords}`)
|
||||
const description = normalize(item.description)
|
||||
const context = normalize(`${item.owner} ${item.entity ? "" : item.page}`)
|
||||
const fuzzy = fuzzysort.single(query, title)?.score ?? 0
|
||||
// Context qualifies a setting match; a project name alone should not return all its controls.
|
||||
if (!tokens.some((token) => `${primary} ${description}`.includes(token)) && fuzzy < 0.6) return []
|
||||
const score =
|
||||
title === query
|
||||
? 5
|
||||
: tokens.every((token) => title.includes(token))
|
||||
? 4
|
||||
: tokens.every((token) => primary.includes(token))
|
||||
? 3
|
||||
: tokens.every((token) => `${primary} ${description} ${context}`.includes(token))
|
||||
? 2
|
||||
: fuzzy >= 0.6
|
||||
? 1
|
||||
: 0
|
||||
if (!score) return []
|
||||
const proximity =
|
||||
origin.type === "project" && origin.server === item.server && origin.project === item.project
|
||||
? 2
|
||||
: origin.type !== "root" && origin.server === item.server
|
||||
? 1
|
||||
: 0
|
||||
return [{ item, score, proximity }]
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(!!b.item.topLevel) - Number(!!a.item.topLevel) ||
|
||||
b.score - a.score ||
|
||||
b.proximity - a.proximity ||
|
||||
a.item.title.localeCompare(b.item.title),
|
||||
)
|
||||
.map((result) => result.item)
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " ")
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { SettingsView } from "./surface"
|
||||
|
||||
/** Reveal one explicit search activation, including targets mounted by an asynchronous scoped page. */
|
||||
export function revealSettingsSearch(root: HTMLElement, view: SettingsView) {
|
||||
const state = { disposed: false, row: undefined as HTMLElement | undefined, tabIndex: null as string | null }
|
||||
const restore = () => {
|
||||
const row = state.row
|
||||
if (!row) return
|
||||
row.removeAttribute("data-search-target")
|
||||
if (state.tabIndex === null) row.removeAttribute("tabindex")
|
||||
if (state.tabIndex !== null) row.setAttribute("tabindex", state.tabIndex)
|
||||
state.row = undefined
|
||||
}
|
||||
const finish = (event: AnimationEvent) => {
|
||||
if (event.target === state.row && event.animationName === "settings-search-reveal") restore()
|
||||
}
|
||||
const reveal = () => {
|
||||
if (state.disposed) return true
|
||||
const panel = root.querySelector<HTMLElement>(".settings-panel:not([hidden])")
|
||||
const control = panel?.querySelector<HTMLElement>(
|
||||
view.target ? `[data-action="${CSS.escape(view.target)}"]` : ".settings-tab-header-row, .settings-about-intro",
|
||||
)
|
||||
if (!panel || !control || !control.getClientRects().length) return false
|
||||
const row =
|
||||
control.closest<HTMLElement>('[data-component="settings-row"], [data-component="settings-list"] > *') ?? control
|
||||
if (!view.target) panel.scrollTop = 0
|
||||
if (view.target) row.scrollIntoView({ block: "center", inline: "nearest" })
|
||||
state.row = row
|
||||
state.tabIndex = row.getAttribute("tabindex")
|
||||
row.setAttribute("data-search-target", view.target ? "row" : "header")
|
||||
// Selecting another result can reuse the same header before the browser paints the removal.
|
||||
row.getAnimations({ subtree: true }).forEach((animation) => {
|
||||
if (!(animation instanceof CSSAnimation) || animation.animationName !== "settings-search-reveal") return
|
||||
animation.currentTime = 0
|
||||
animation.play()
|
||||
})
|
||||
if (view.type !== "root") {
|
||||
const focus = view.target ? row : (panel.querySelector<HTMLInputElement>(".settings-tab-search input") ?? row)
|
||||
if (focus === row) row.tabIndex = -1
|
||||
focus.focus({ preventScroll: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (reveal()) observer.disconnect()
|
||||
})
|
||||
root.addEventListener("animationend", finish)
|
||||
queueMicrotask(() => {
|
||||
if (!reveal())
|
||||
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "class"] })
|
||||
})
|
||||
return () => {
|
||||
state.disposed = true
|
||||
observer.disconnect()
|
||||
root.removeEventListener("animationend", finish)
|
||||
restore()
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
.settings-search,
|
||||
.settings-search-matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-search[data-empty="true"] [data-slot="text-input-v2-icon-button"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.settings-search > [data-component="text-input-v2"] {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-search-group,
|
||||
.settings-search-detail,
|
||||
.settings-search-note {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.settings-search-results {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-search-group {
|
||||
padding: 16px 6px 6px;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.settings-search-group:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.settings-search-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-search-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-search-result[data-compact="true"] {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.settings-search-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.settings-search-project-icon[data-component="project-avatar-v2"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.settings-search-result[data-highlighted="true"],
|
||||
.settings-search-result[aria-current="location"],
|
||||
.settings-search-result:active {
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
.settings-search-result:focus-visible {
|
||||
outline: none;
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.settings-search-result:hover:not([data-highlighted="true"]):not([aria-current="location"]):not(:active) {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-search-empty {
|
||||
padding: 20px 6px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-search-note {
|
||||
padding: 12px 6px 0;
|
||||
}
|
||||
|
||||
[data-component="settings-list"] {
|
||||
--settings-search-inset: 20px;
|
||||
}
|
||||
|
||||
[data-component="settings-list"][data-variant="catalog"] {
|
||||
--settings-search-inset: 16px;
|
||||
}
|
||||
|
||||
[data-search-target] {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-search-target]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline: calc(-1 * var(--settings-search-inset, 0px));
|
||||
z-index: -1;
|
||||
border-radius: 6px;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
pointer-events: none;
|
||||
animation: settings-search-reveal 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
[data-search-target="header"]::before {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes settings-search-reveal {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-search-target]::before {
|
||||
animation-timing-function: steps(1, end);
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { createEffect, createMemo, createUniqueId, For, on, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { settingsProjects, useSettingsServers } from "./servers/inventory"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { pageIcons } from "./pages"
|
||||
import { rankSettings, type SettingsSearchResult } from "./search-results"
|
||||
import { settingsSearchIndex } from "./search-index"
|
||||
|
||||
export function SettingsSearch() {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const platform = usePlatform()
|
||||
const global = useGlobal()
|
||||
const servers = useSettingsServers()
|
||||
const surface = useSettingsSurface()
|
||||
const search = surface.search
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const [state, setState] = createStore({ narrow: false })
|
||||
const listID = `settings-results-${createUniqueId()}`
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let results: HTMLDivElement | undefined
|
||||
onMount(() => {
|
||||
const screen = root?.closest<HTMLElement>(".settings-screen")
|
||||
if (!screen) return
|
||||
setState("narrow", screen.clientWidth < 800)
|
||||
createResizeObserver(screen, (rect) => setState("narrow", rect.width < 800))
|
||||
})
|
||||
command.register("settings.search", () => [
|
||||
{
|
||||
id: "settings.search.focus",
|
||||
title: language.t("settings.search.placeholder"),
|
||||
keybind: "mod+f",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
input?.focus({ preventScroll: true })
|
||||
input?.select()
|
||||
},
|
||||
},
|
||||
])
|
||||
const inventory = createMemo(() =>
|
||||
servers().map((server) => {
|
||||
const context = server.connection ? global.ensureServerCtx(server.connection) : undefined
|
||||
return {
|
||||
...server,
|
||||
connected: context?.sdk.connection.status() === "connected",
|
||||
projects: context ? settingsProjects(context) : [],
|
||||
}
|
||||
}),
|
||||
)
|
||||
const origin = () => search.state.origin ?? surface.view()
|
||||
const catalog = createMemo(() =>
|
||||
settingsSearchIndex({
|
||||
servers: inventory(),
|
||||
desktop: platform.platform === "desktop",
|
||||
browser: !!platform.browserPane,
|
||||
dev: import.meta.env.VITE_OPENCODE_CHANNEL !== "prod",
|
||||
mobile: mobile(),
|
||||
translate: language.t,
|
||||
}),
|
||||
)
|
||||
const matches = createMemo(() => rankSettings(search.state.query, catalog(), origin()))
|
||||
const shown = createMemo(() => matches().slice(0, 60))
|
||||
const expanded = () => !!search.state.query.trim() && (!state.narrow || search.state.expanded)
|
||||
const highlighted = () => shown().find((item) => item.id === search.state.highlighted) ?? shown()[0]
|
||||
const optionID = (id: string) => `${listID}-${encodeURIComponent(id)}`
|
||||
createEffect(
|
||||
on(
|
||||
() => search.state.query,
|
||||
() => {
|
||||
if (results) results.scrollTop = 0
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const group = (item: SettingsSearchResult) => {
|
||||
if (!item.server || servers().length > 1) return item.owner
|
||||
return item.projectName ?? ""
|
||||
}
|
||||
const select = (item: SettingsSearchResult) => {
|
||||
surface.search.open(item.view, item.id)
|
||||
if (state.narrow && item.view.type === "root") {
|
||||
input?.blur()
|
||||
root?.closest<HTMLElement>(".settings-screen")?.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
const clear = () => {
|
||||
search.clear()
|
||||
input?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
class="settings-search"
|
||||
data-expanded={search.state.expanded}
|
||||
data-empty={!search.state.query.trim()}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.isComposing ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
)
|
||||
return
|
||||
if (
|
||||
event.target !== input &&
|
||||
!(event.target instanceof Element && event.target.closest(".settings-search-result"))
|
||||
)
|
||||
return
|
||||
if (event.key === "Escape" && search.state.query) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
clear()
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && expanded() && highlighted()) {
|
||||
event.preventDefault()
|
||||
select(highlighted()!)
|
||||
return
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
|
||||
if ((event.key === "Home" || event.key === "End") && event.target === input) return
|
||||
event.preventDefault()
|
||||
search.expand()
|
||||
const index = shown().findIndex((item) => item.id === highlighted()?.id)
|
||||
const next =
|
||||
shown()[
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? shown().length - 1
|
||||
: Math.max(0, Math.min(shown().length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))
|
||||
]
|
||||
if (!next) return
|
||||
search.highlight(next.id)
|
||||
const row = results?.querySelector<HTMLElement>(`#${CSS.escape(optionID(next.id))}`)
|
||||
if (event.target !== input) row?.focus({ preventScroll: true })
|
||||
row?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
ref={input}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={expanded()}
|
||||
aria-activedescendant={expanded() && highlighted() ? optionID(highlighted()!.id) : undefined}
|
||||
value={search.state.query}
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
placeholder={language.t("settings.search.placeholder")}
|
||||
aria-label={language.t("settings.search.placeholder")}
|
||||
aria-controls={search.state.query.trim() ? listID : undefined}
|
||||
showClearButton
|
||||
onClearClick={clear}
|
||||
onFocus={() => search.expand()}
|
||||
onInput={(event) => {
|
||||
search.input(event.currentTarget.value)
|
||||
}}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Show when={search.state.query.trim()}>
|
||||
<div class="settings-search-matches">
|
||||
<div
|
||||
ref={(element) => {
|
||||
results = element
|
||||
queueMicrotask(() => {
|
||||
if (element.isConnected) element.scrollTop = search.state.scrollTop
|
||||
})
|
||||
}}
|
||||
id={listID}
|
||||
class="settings-search-results"
|
||||
role="listbox"
|
||||
aria-label={language.t("settings.search.results")}
|
||||
onScroll={(event) => search.scroll(event.currentTarget.scrollTop)}
|
||||
>
|
||||
<For each={shown()}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
<Show when={group(item) && (index() === 0 || group(shown()[index() - 1]) !== group(item))}>
|
||||
<div class="settings-search-group" role="presentation">
|
||||
<bdi dir="auto">{group(item)}</bdi>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
id={optionID(item.id)}
|
||||
role="option"
|
||||
aria-selected={highlighted()?.id === item.id}
|
||||
aria-label={
|
||||
item.page === item.title && !item.owner
|
||||
? item.title
|
||||
: language.t(
|
||||
item.page === item.title
|
||||
? "settings.search.page"
|
||||
: item.owner
|
||||
? "settings.search.result"
|
||||
: "settings.search.result.unscoped",
|
||||
{ title: item.title, scope: item.owner, page: item.page },
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
class="settings-search-result"
|
||||
data-compact={item.page === item.title}
|
||||
data-setting-target={item.view.target}
|
||||
data-result-id={item.id}
|
||||
data-highlighted={highlighted()?.id === item.id}
|
||||
aria-current={search.state.selected === item.id ? "location" : undefined}
|
||||
title={[item.owner, item.page].filter(Boolean).join(" › ")}
|
||||
tabIndex={highlighted()?.id === item.id ? 0 : -1}
|
||||
onFocus={() => search.highlight(item.id)}
|
||||
onClick={(event) => {
|
||||
if (item.view.type === "root" && !state.narrow) event.currentTarget.focus({ preventScroll: true })
|
||||
select(item)
|
||||
}}
|
||||
>
|
||||
<span class="settings-search-label">
|
||||
<Show
|
||||
when={item.projectInfo}
|
||||
fallback={
|
||||
<Show when={item.topLevel}>
|
||||
<Icon name={pageIcons[item.view.tab]} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(project) => (
|
||||
<ProjectIcon project={project()} class="settings-search-project-icon" aria-hidden="true" />
|
||||
)}
|
||||
</Show>
|
||||
<bdi dir="auto" class="settings-search-title">
|
||||
{item.title}
|
||||
</bdi>
|
||||
</span>
|
||||
<Show when={item.page !== item.title}>
|
||||
<span class="settings-search-detail">{item.page}</span>
|
||||
</Show>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!matches().length}>
|
||||
<div class="settings-search-empty" role="status">
|
||||
{language.t("settings.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={matches().length > shown().length}>
|
||||
<p class="settings-search-note">{language.t("settings.search.refine")}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { type ParentProps } from "solid-js"
|
||||
import { type ParentProps, Show } from "solid-js"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
|
||||
const global = useGlobal()
|
||||
return (
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||
{(server) => (
|
||||
<SettingsServerDataScope server={server} directory={props.directory}>
|
||||
{props.children}
|
||||
</SettingsServerDataScope>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any; directory?: string }>) {
|
||||
return (
|
||||
<ServerProvider conn={props.server}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Show, createMemo, type Component } from "solid-js"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
|
||||
const allServers = { type: "all" } as const
|
||||
type ServerOption = ServerConnection.Any | typeof allServers
|
||||
|
||||
export const InlineServerSelect: Component<{
|
||||
all?: {
|
||||
label: string
|
||||
selected: () => boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
onServerSelect?: () => void
|
||||
}> = (props) => {
|
||||
const global = useGlobal()
|
||||
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
||||
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
||||
|
||||
return (
|
||||
<Show when={options().length > 1}>
|
||||
<Select
|
||||
data-action="settings-server-select"
|
||||
options={options()}
|
||||
current={current()}
|
||||
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
||||
label={(server) =>
|
||||
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
||||
}
|
||||
optionDisabled={(server) =>
|
||||
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
||||
}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(server) => {
|
||||
if (!server) return
|
||||
if (server.type === "all") {
|
||||
props.all?.onSelect()
|
||||
return
|
||||
}
|
||||
global.settings.server.set(ServerConnection.key(server))
|
||||
props.onServerSelect?.()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { SshItem } from "@/servers/ssh/types"
|
||||
import { settingsServers } from "./inventory"
|
||||
|
||||
const ssh: SshItem = {
|
||||
config: { id: "build", target: "dev@example.com", name: "Build server" },
|
||||
saved: true,
|
||||
stage: "disconnected",
|
||||
detail: "",
|
||||
}
|
||||
const connection: ServerConnection.Ssh = {
|
||||
type: "ssh",
|
||||
id: ssh.config.id,
|
||||
host: ssh.config.target,
|
||||
displayName: ssh.config.name,
|
||||
http: { url: "http://127.0.0.1:4000", password: "secret" },
|
||||
}
|
||||
|
||||
describe("settings server inventory", () => {
|
||||
test("includes saved SSH servers before they connect", () => {
|
||||
expect(settingsServers([], [], [ssh])).toEqual([
|
||||
{
|
||||
key: ServerConnection.Key.make("ssh:build"),
|
||||
name: "Build server",
|
||||
ssh,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("joins ready SSH state to its live connection", () => {
|
||||
const ready = { ...ssh, stage: "ready" as const }
|
||||
expect(settingsServers([connection], [], [ready])).toEqual([
|
||||
{
|
||||
key: ServerConnection.Key.make("ssh:build"),
|
||||
name: "Build server",
|
||||
connection,
|
||||
ssh: ready,
|
||||
wsl: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits unsaved SSH state and withholds stale connections while disconnected", () => {
|
||||
expect(settingsServers([], [], [{ ...ssh, saved: false }])).toEqual([])
|
||||
expect(settingsServers([connection], [], [ssh])[0].connection).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { useWslServers } from "@/servers/wsl/context"
|
||||
import type { WslServerItem } from "@/servers/wsl/types"
|
||||
import { useSsh } from "@/servers/ssh/context"
|
||||
import { sshName, type SshItem } from "@/servers/ssh/types"
|
||||
import type { ServerCtx } from "@/runtime/server/runtime"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
|
||||
export function settingsProjects(context: ServerCtx) {
|
||||
const tracked = context.projects.list()
|
||||
const paths = new Set(tracked.map((project) => pathKey(project.worktree)))
|
||||
return [
|
||||
...tracked,
|
||||
...context.sync.data.project
|
||||
.filter((project) => !paths.has(pathKey(project.worktree)))
|
||||
.map((project) => ({ ...project, expanded: false })),
|
||||
]
|
||||
}
|
||||
|
||||
export type SettingsServer = {
|
||||
key: ServerConnection.Key
|
||||
name: string
|
||||
connection?: ServerConnection.Any
|
||||
wsl?: WslServerItem
|
||||
ssh?: SshItem
|
||||
}
|
||||
|
||||
export function settingsServers(
|
||||
connections: readonly ServerConnection.Any[],
|
||||
wsl: readonly WslServerItem[],
|
||||
ssh: readonly SshItem[],
|
||||
) {
|
||||
const configured = new Map(wsl.map((item) => [item.config.id, item]))
|
||||
const saved = new Map(ssh.filter((item) => item.saved).map((item) => [`ssh:${item.config.id}`, item]))
|
||||
const connected = new Set(connections.map(ServerConnection.key))
|
||||
return [
|
||||
...connections.map((connection): SettingsServer => {
|
||||
const key = ServerConnection.key(connection)
|
||||
const item = configured.get(key)
|
||||
const remote = saved.get(key)
|
||||
return {
|
||||
key,
|
||||
name: item?.config.distro ?? (remote ? sshName(remote.config) : serverName(connection) || key),
|
||||
connection:
|
||||
(item && item.runtime.kind !== "ready") || (remote && remote.stage !== "ready") ? undefined : connection,
|
||||
wsl: item,
|
||||
ssh: remote,
|
||||
}
|
||||
}),
|
||||
...wsl
|
||||
.filter((item) => !connected.has(ServerConnection.Key.make(item.config.id)))
|
||||
.map(
|
||||
(item): SettingsServer => ({
|
||||
key: ServerConnection.Key.make(item.config.id),
|
||||
name: item.config.distro,
|
||||
wsl: item,
|
||||
}),
|
||||
),
|
||||
...ssh
|
||||
.filter((item) => item.saved && !connected.has(ServerConnection.Key.make(`ssh:${item.config.id}`)))
|
||||
.map(
|
||||
(item): SettingsServer => ({
|
||||
key: ServerConnection.Key.make(`ssh:${item.config.id}`),
|
||||
name: sshName(item.config),
|
||||
ssh: item,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function useSettingsServers() {
|
||||
const servers = useServers()
|
||||
const wsl = useWslServers()
|
||||
const ssh = useSsh()
|
||||
return createMemo(() => settingsServers(servers.list, wsl.data?.servers ?? [], ssh.servers))
|
||||
}
|
||||
@@ -1,140 +1,139 @@
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createMemo, Show, type Component } from "solid-js"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Component, For, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerRowMenu } from "@/servers/registry/row-menu"
|
||||
import { ServerHealthIndicator } from "@/servers/registry/row"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useServerCollectionController } from "@/servers/registry/controller"
|
||||
import { DialogServer } from "@/servers/connect/dialog"
|
||||
import { AddServerMenu, WslServerSettings } from "@/servers/wsl/settings"
|
||||
import { SshServerSettings } from "@/servers/ssh/settings"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { ShellSetting } from "@/settings/general/general"
|
||||
import { createServerPreferencesController } from "@/settings/general/controllers"
|
||||
import type { SettingsServer } from "./inventory"
|
||||
import { SshServerSettings } from "@/servers/ssh/settings"
|
||||
import { useSsh } from "@/servers/ssh/context"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/servers/wsl/settings"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const WebSearchSetting: Component<{
|
||||
controller: ReturnType<typeof createServerPreferencesController>["websearch"]
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.server.preferences.websearch.title")}
|
||||
description={language.t("settings.server.preferences.websearch.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-websearch"
|
||||
options={props.controller.options()}
|
||||
current={props.controller.current()}
|
||||
value={(option) => String(option.value)}
|
||||
label={(option) => option.label}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && props.controller.select(option.value)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsServerGeneral: Component<{
|
||||
entry: SettingsServer
|
||||
nested?: boolean
|
||||
onAddServer?: () => void
|
||||
onServerChange?: (server: ServerConnection.Any) => void
|
||||
}> = (props) => {
|
||||
export const SettingsServers: Component = () => {
|
||||
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(() => <DialogServer mode="edit" server={server} onSave={props.onServerChange} />)
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
const ssh = useSsh()
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => controller.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = controller.collection.items().filter((item) => !isWslServer(item) && 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(() => <DialogServer mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
void dialog.push(() => <DialogServer mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div
|
||||
class="settings-tab-header settings-servers-header"
|
||||
classList={{ "settings-tab-header--stacked": showSearch() }}
|
||||
>
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">
|
||||
{props.nested ? props.entry.name : language.t("settings.section.server")}
|
||||
</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t(props.nested ? "settings.server.description" : "settings.servers.description")}
|
||||
</span>
|
||||
<h2 class="settings-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
||||
</div>
|
||||
<Show when={!props.nested && props.onAddServer}>
|
||||
<AddServerMenu onAddServer={() => props.onAddServer?.()} />
|
||||
</Show>
|
||||
<AddServerMenu onAddServer={openAdd} />
|
||||
</div>
|
||||
<Show when={showSearch()}>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.server.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("dialog.server.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.filter}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setStore("filter", "")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<section class="settings-section settings-server-connection" data-component="settings-server-connection">
|
||||
<h3 class="settings-section-title">{language.t("settings.server.section.connection")}</h3>
|
||||
<div class="settings-tab-body settings-servers">
|
||||
<Show
|
||||
when={filtered().length > 0 || wslServers().length > 0 || ssh.servers.some((item) => item.saved)}
|
||||
fallback={
|
||||
<div class="settings-servers-status">
|
||||
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
|
||||
<Show when={store.filter}>
|
||||
<span class="settings-servers-status-filter">"{store.filter}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<Show
|
||||
when={props.entry.ssh}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.entry.wsl}
|
||||
fallback={
|
||||
<Show when={props.entry.connection}>
|
||||
{(server) => (
|
||||
<div class="settings-servers-row">
|
||||
<div class="settings-servers-lead">
|
||||
<ServerHealthIndicator health={health()} />
|
||||
<div class="settings-servers-copy">
|
||||
<bdi class="settings-servers-name" dir="auto">
|
||||
{serverName(server()) || props.entry.key}
|
||||
</bdi>
|
||||
<bdi class="settings-servers-meta" dir="ltr">
|
||||
{server().http.url}
|
||||
</bdi>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-servers-actions">
|
||||
<Show
|
||||
when={controller.defaults.available() && controller.defaults.key() === props.entry.key}
|
||||
>
|
||||
<Badge>{language.t("dialog.server.status.default")}</Badge>
|
||||
</Show>
|
||||
<ServerRowMenu server={server()} domain={controller} onEdit={edit} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) => <WslServerSettings domain={controller} servers={() => [item()]} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) => <SshServerSettings filter="" id={item().config.id} domain={controller} />}
|
||||
</Show>
|
||||
<SshServerSettings filter={store.filter} domain={controller} />
|
||||
<WslServerSettings domain={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => controller.collection.health()[key]
|
||||
const isDefault = () => controller.defaults.key() === key
|
||||
return (
|
||||
<div class="settings-servers-row">
|
||||
<div class="settings-servers-lead">
|
||||
<ServerHealthIndicator health={health()} />
|
||||
<div class="settings-servers-copy">
|
||||
<span class="settings-servers-name">{serverName(item)}</span>
|
||||
<Show when={health()?.version}>
|
||||
<span class="settings-servers-meta">v{health()?.version}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-servers-actions">
|
||||
<Show when={controller.defaults.available() && isDefault()}>
|
||||
<Badge>{language.t("dialog.server.status.default")}</Badge>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} domain={controller} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</section>
|
||||
|
||||
<Show when={props.entry.connection} keyed>
|
||||
{(server) => <ServerPreferences server={server} />}
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerPreferences(props: { server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const preferences = createServerPreferencesController(() => props.server)
|
||||
return (
|
||||
<section class="settings-section">
|
||||
<h3 class="settings-section-title">{language.t("settings.tab.preferences")}</h3>
|
||||
<SettingsList>
|
||||
<ShellSetting controller={preferences.shell} />
|
||||
<WebSearchSetting controller={preferences.websearch} />
|
||||
</SettingsList>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,31 +37,27 @@
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> .settings-sidebar {
|
||||
min-height: 0;
|
||||
flex-shrink: 0;
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 328px;
|
||||
min-width: 328px;
|
||||
padding-block: 48px;
|
||||
padding-inline-start: 24px;
|
||||
padding-inline-end: 64px;
|
||||
padding-inline-end: 104px;
|
||||
border-inline-end: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-screen > .settings > .settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.settings-screen > .settings > .settings-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 720px;
|
||||
/* Leave room for raised card shadows inside the scrollport. */
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-header {
|
||||
padding: 48px 0 0;
|
||||
padding: 48px 0 32px;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-body {
|
||||
@@ -70,98 +66,12 @@
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-nav-groups {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"] {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:hover {
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content > .settings-panel {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -170,13 +80,6 @@
|
||||
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;
|
||||
@@ -288,7 +191,6 @@
|
||||
|
||||
.settings-back {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -349,18 +251,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
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;
|
||||
padding: 40px 40px 32px;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||
}
|
||||
|
||||
.settings-tab-header-row {
|
||||
@@ -374,20 +266,16 @@
|
||||
.settings-tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: var(--line-height-base);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
padding: 24px 40px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
}
|
||||
|
||||
.settings-tab-body--sectioned {
|
||||
padding-top: 32px;
|
||||
padding: 0 40px calc(80px + var(--settings-bottom-inset, 0px));
|
||||
}
|
||||
|
||||
[data-slot="settings-row-description"] a.settings-link {
|
||||
@@ -407,18 +295,16 @@
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 640;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-section-stack {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
.settings-section-title + [data-component="settings-list"] {
|
||||
margin-top: -4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="settings-list"] {
|
||||
@@ -428,12 +314,6 @@
|
||||
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);
|
||||
@@ -444,7 +324,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-block: var(--settings-list-row-padding, 20px);
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
@@ -497,12 +377,6 @@
|
||||
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;
|
||||
@@ -539,7 +413,7 @@
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> .settings-sidebar {
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
padding-inline-start: 16px;
|
||||
@@ -579,7 +453,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-screen > .settings > .settings-content {
|
||||
.settings-screen > .settings > .settings-panel {
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
}
|
||||
@@ -593,48 +467,16 @@
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.settings-screen .settings-tab-title,
|
||||
.settings-screen .settings-section-title {
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.settings-screen
|
||||
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||
> .settings-sidebar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px var(--settings-mobile-inner-inset, 16px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.settings-nav > .settings-back,
|
||||
.settings-sidebar .settings-nav-groups[data-slot="tabs-v2-list"] {
|
||||
> [data-slot="tabs-v2-list"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-search[data-expanded="false"] .settings-search-matches {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-search-matches {
|
||||
position: absolute;
|
||||
inset-block-start: calc(100% + 8px);
|
||||
inset-inline: 0;
|
||||
z-index: 12;
|
||||
max-height: 45vh;
|
||||
padding-block: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
box-shadow: var(--v2-elevation-raised);
|
||||
}
|
||||
|
||||
.settings-screen > .settings > .settings-sidebar[data-searchable="false"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-search [data-slot="text-input-v2-input"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
@@ -665,7 +507,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-block: var(--settings-list-row-padding, 20px);
|
||||
padding-block: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
@@ -694,7 +536,7 @@
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
gap: var(--settings-list-icon-gap, 10px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-provider-lead:not(:has(.settings-provider-copy)) {
|
||||
@@ -720,12 +562,12 @@
|
||||
.settings-provider-name {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 16px;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-provider-description {
|
||||
margin: 0;
|
||||
margin-block: -3.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
@@ -733,7 +575,7 @@
|
||||
}
|
||||
|
||||
.settings-provider-empty {
|
||||
padding-block: var(--settings-list-row-padding, 20px);
|
||||
padding-block: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
@@ -772,10 +614,30 @@
|
||||
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: 24px;
|
||||
gap: 32px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.settings-tab-header--stacked > .settings-tab-header-row {
|
||||
@@ -868,16 +730,25 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-models-group-title {
|
||||
.settings-models .settings-section-title {
|
||||
padding-bottom: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -923,6 +794,17 @@
|
||||
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;
|
||||
@@ -1011,23 +893,39 @@
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-servers-row {
|
||||
.settings-tab-body.settings-servers {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-tab-header.settings-servers-header {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-servers-header .settings-tab-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-server-connection [data-component="settings-list"] {
|
||||
padding-inline: 16px;
|
||||
.settings-tab-header.settings-servers-header.settings-tab-header--stacked {
|
||||
gap: 24px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-server-connection .settings-servers-row {
|
||||
padding-block: 20px;
|
||||
.settings-servers [data-component="settings-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-server-connection .settings-servers-lead {
|
||||
gap: 4px;
|
||||
.settings-servers-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-servers-row:not(:last-child) {
|
||||
@@ -1074,8 +972,34 @@
|
||||
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: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-workspaces-toolbar {
|
||||
@@ -1084,7 +1008,13 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-workspaces-count {
|
||||
font-size: 15px;
|
||||
font-weight: 530;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-toolbar-actions {
|
||||
@@ -1327,7 +1257,7 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-workspaces-header {
|
||||
padding: 24px 20px 0;
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
.settings-tab-body.settings-workspaces {
|
||||
@@ -1443,68 +1373,20 @@
|
||||
|
||||
.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-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 {
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.settings-subtabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||
> [data-slot="tabs-v2-list"]
|
||||
[data-slot="tabs-v2-trigger"] {
|
||||
padding-inline: 8px;
|
||||
|
||||
+203
-438
@@ -1,18 +1,10 @@
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
|
||||
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 { 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"
|
||||
@@ -20,131 +12,94 @@ import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
import { SettingsModels } from "./models/models"
|
||||
import { SettingsServerGeneral } from "./servers/servers"
|
||||
import { useSettingsServers, type SettingsServer } from "./servers/inventory"
|
||||
import { SettingsServers } from "./servers/servers"
|
||||
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 { SettingsServerDataScope } from "./server-scope"
|
||||
import { SettingsNavigation, type SettingsNavGroup } from "./navigation"
|
||||
import { SettingsProjectGeneral } from "./workspaces/project"
|
||||
import { ProjectSettingsExtensions } from "./workspaces/project-extensions"
|
||||
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 { useSettingsSurface } from "./surface"
|
||||
import { pageIcons } from "./pages"
|
||||
import { revealSettingsSearch } from "./search-reveal"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const rootClientTabs = [
|
||||
{ value: "general", icon: pageIcons.general, label: "settings.tab.preferences" },
|
||||
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
|
||||
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
|
||||
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
|
||||
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" }],
|
||||
] as const
|
||||
|
||||
const serverTabs = [
|
||||
{ value: "projects", icon: pageIcons.projects, label: "settings.tab.projects" },
|
||||
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
|
||||
{ value: "providers", icon: pageIcons.providers, label: "settings.providers.title" },
|
||||
{ value: "models", icon: pageIcons.models, label: "settings.models.title" },
|
||||
{ value: "extensions", icon: pageIcons.extensions, label: "settings.tab.extensions" },
|
||||
] as const
|
||||
|
||||
const trailingTabs = [
|
||||
[{ value: "experimental", icon: pageIcons.experimental, label: "settings.tab.experimental" }],
|
||||
[{ value: "about", icon: pageIcons.about, label: "settings.tab.about" }],
|
||||
] as const
|
||||
|
||||
const nestedServerTabs = [
|
||||
{ value: "general", icon: pageIcons.servers, label: "settings.general.section.general" },
|
||||
...serverTabs,
|
||||
] as const
|
||||
|
||||
const nestedProjectTabs = [
|
||||
{ value: "general", icon: pageIcons.projects, label: "settings.general.section.general" },
|
||||
{ value: "workspaces", icon: pageIcons.workspaces, label: "settings.tab.workspaces" },
|
||||
{ value: "extensions", icon: pageIcons.extensions, label: "settings.tab.extensions" },
|
||||
] as const
|
||||
|
||||
export function SettingsScreen() {
|
||||
const surface = useSettingsSurface()
|
||||
export const SettingsScreen: Component = () => {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const servers = useSettingsServers()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
let root: HTMLDivElement | undefined
|
||||
let viewType = surface.view().type
|
||||
let activation = 0
|
||||
|
||||
onMount(() =>
|
||||
(root?.querySelector<HTMLInputElement>(".settings-search input") ?? root)?.focus({ preventScroll: true }),
|
||||
)
|
||||
createEffect(() => {
|
||||
const next = surface.view().type
|
||||
if (next === viewType) return
|
||||
viewType = next
|
||||
queueMicrotask(() => {
|
||||
const target =
|
||||
surface.search.state.query.trim() && surface.search.state.expanded
|
||||
? root?.querySelector<HTMLInputElement>(".settings-search input")
|
||||
: root
|
||||
target?.focus({ preventScroll: true })
|
||||
})
|
||||
onMount(() => {
|
||||
root?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [surface.view(), surface.search.state.selected] as const,
|
||||
([view, selected]) => {
|
||||
if (
|
||||
!root ||
|
||||
!selected ||
|
||||
!view.searchActivation ||
|
||||
view.searchActivation !== surface.search.state.activation ||
|
||||
view.searchActivation === activation
|
||||
)
|
||||
return
|
||||
activation = view.searchActivation
|
||||
onCleanup(revealSettingsSearch(root, view))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const connection = (key: string) => servers().find((item) => item.key === key)
|
||||
const project = (server: ServerConnection.Any, directory: string) => {
|
||||
const context = global.ensureServerCtx(server)
|
||||
const value =
|
||||
context.projects.list().find((item) => item.worktree === directory) ??
|
||||
context.sync.data.project.find((item) => item.worktree === directory)
|
||||
return value ? { expanded: false, ...value } : undefined
|
||||
}
|
||||
const targetServer = createMemo(() => {
|
||||
const view = surface.view()
|
||||
if (view.type === "root") return undefined
|
||||
return connection(view.server)
|
||||
})
|
||||
const targetProject = createMemo(() => {
|
||||
const view = surface.view()
|
||||
const server = targetServer()
|
||||
if (view.type !== "project" || !server) return undefined
|
||||
return server.connection && project(server.connection, view.project)
|
||||
})
|
||||
createEffect(() => {
|
||||
const view = surface.view()
|
||||
if (view.type === "root") return
|
||||
const target = targetServer()
|
||||
if (!target) {
|
||||
surface.back()
|
||||
return
|
||||
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)
|
||||
}
|
||||
if (view.type === "project" && !target.connection) surface.replaceServer(target.key)
|
||||
})
|
||||
const serverCtx = useServerCtx(server)
|
||||
|
||||
createEffect(() => {
|
||||
const view = surface.view()
|
||||
if (view.type !== "server" || servers().length !== 1) return
|
||||
surface.open(view.tab === "general" ? "servers" : view.tab)
|
||||
const current = server()
|
||||
if (current) global.settings.server.set(ServerConnection.key(current))
|
||||
})
|
||||
|
||||
const directory = createMemo(() => {
|
||||
const selected = global.settings.server.selected()
|
||||
const current = server()
|
||||
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
|
||||
const route = surface.route()
|
||||
if (route.type === "draft") {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
const showProviders = () => {
|
||||
dialog.close()
|
||||
surface.open("providers")
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
@@ -154,328 +109,138 @@ export function SettingsScreen() {
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
|
||||
event.preventDefault()
|
||||
if (surface.view().type !== "root" && surface.search.back()) return
|
||||
if (surface.search.state.query.trim()) {
|
||||
surface.search.clear()
|
||||
return
|
||||
}
|
||||
surface.back()
|
||||
surface.close()
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={surface.view().type === "root"}>
|
||||
<RootSettings />
|
||||
</Match>
|
||||
<Match when={surface.view().type === "server"}>
|
||||
<Show when={targetServer()}>{(server) => <ServerSettings entry={server()} />}</Show>
|
||||
</Match>
|
||||
<Match when={surface.view().type === "project"}>
|
||||
<Show when={targetServer()?.connection} keyed>
|
||||
{(server) => (
|
||||
<Show when={targetProject()}>{(project) => <ProjectSettings server={server} project={project()} />}</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
class="settings"
|
||||
>
|
||||
<div class="settings-mobile-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<Menu placement="bottom-end" gutter={8}>
|
||||
<Menu.Trigger as={Button} size="normal" variant="outline" class="settings-mobile-menu-trigger">
|
||||
<span>
|
||||
{language.t(
|
||||
sections.flat().find((section) => section.value === surface.tab())?.label ??
|
||||
"settings.tab.preferences",
|
||||
)}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-mobile-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.RadioGroup
|
||||
value={surface.tab()}
|
||||
onChange={(value) => void startTransition(() => surface.open(value))}
|
||||
>
|
||||
<For each={sections}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Menu.RadioItem
|
||||
value={section.value}
|
||||
closeOnSelect
|
||||
onSelect={() => {
|
||||
if (section.value === "workspaces")
|
||||
setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
<Tabs.List>
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<For each={sections}>
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger
|
||||
value={section.value}
|
||||
onClick={() => {
|
||||
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
<SettingsGeneral server={server()} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="appearance" class="settings-panel">
|
||||
<SettingsAppearance />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="notifications" class="settings-panel">
|
||||
<SettingsNotifications />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="servers" class="settings-panel">
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects />
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={directory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={directory()} onBack={showProviders} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={surface.tab() === "about"} />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RootSettings() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const servers = useServerCollectionController()
|
||||
const inventory = useSettingsServers()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const list = servers.collection.items
|
||||
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
|
||||
const single = createMemo(() => singleEntry()?.connection)
|
||||
const 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(() => (
|
||||
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
|
||||
))
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
|
||||
...(multiple()
|
||||
? [
|
||||
{
|
||||
label: language.t("status.popover.tab.servers"),
|
||||
action: <AddServerMenu compact onAddServer={addServer} />,
|
||||
items: ordered().map((server) => ({
|
||||
value: `server:${server.key}`,
|
||||
icon: "server" as const,
|
||||
label: server.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
items: [
|
||||
...serverTabs.map((item) => ({
|
||||
...item,
|
||||
label: language.t(item.label),
|
||||
disabled: !single(),
|
||||
onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined,
|
||||
})),
|
||||
{ value: "servers", icon: "server" as const, label: language.t("settings.section.server") },
|
||||
],
|
||||
},
|
||||
]),
|
||||
...trailingTabs.map((items) => ({ items: items.map((item) => ({ ...item, label: language.t(item.label) })) })),
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
const view = surface.view()
|
||||
if (view.type !== "root" || !multiple()) return
|
||||
if (["projects", "workspaces", "providers", "models", "extensions", "servers"].includes(view.tab))
|
||||
surface.open("general")
|
||||
})
|
||||
|
||||
const change = (value: string) => {
|
||||
if (value.startsWith("server:")) {
|
||||
surface.openServer(value.slice("server:".length))
|
||||
return
|
||||
}
|
||||
if (value === "workspaces") setState("worktreeFilterReset", (current) => current + 1)
|
||||
surface.select(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsNavigation
|
||||
value={surface.view().tab}
|
||||
groups={groups()}
|
||||
backLabel={language.t("settings.backToApp")}
|
||||
onBack={() => surface.close()}
|
||||
onChange={change}
|
||||
mobileAction={multiple() ? <AddServerMenu compact onAddServer={addServer} /> : undefined}
|
||||
>
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
<SettingsGeneral />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="appearance" class="settings-panel">
|
||||
<SettingsAppearance />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="notifications" class="settings-panel">
|
||||
<SettingsNotifications />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="about" class="settings-panel settings-about">
|
||||
<SettingsAbout active={surface.view().tab === "about"} />
|
||||
</Tabs.Content>
|
||||
<Show when={single()} keyed>
|
||||
{(server) => (
|
||||
<SettingsServerDataScope server={server}>
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
autofocus={!surface.search.state.selected}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: ServerConnection.key(server),
|
||||
project: project.worktree,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={sourceServer() === server ? sourceDirectory() : undefined}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels active={surface.view().tab === "models"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
</Tabs.Content>
|
||||
</SettingsServerDataScope>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={singleEntry()}>
|
||||
{(entry) => (
|
||||
<Tabs.Content value="servers" class="settings-panel">
|
||||
<SettingsServerGeneral entry={entry()} onAddServer={addServer} />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</Show>
|
||||
</SettingsNavigation>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerSettings(props: { entry: SettingsServer }) {
|
||||
const language = useLanguage()
|
||||
const surface = useSettingsSurface()
|
||||
const activeDirectory = useSettingsDirectory(() => props.entry.connection)
|
||||
const prefetchWorkspaces = useWorkspacesPrefetch(() => props.entry.connection)
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{
|
||||
items: nestedServerTabs.map((item) => ({
|
||||
...item,
|
||||
label: item.value === "general" ? props.entry.name : language.t(item.label),
|
||||
disabled: item.value !== "general" && !props.entry.connection,
|
||||
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 (
|
||||
<SettingsNavigation
|
||||
value={surface.view().tab}
|
||||
groups={groups()}
|
||||
backLabel={language.t("settings.backToSettings")}
|
||||
onBack={() => surface.back()}
|
||||
onChange={change}
|
||||
>
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
<SettingsServerGeneral
|
||||
entry={props.entry}
|
||||
nested
|
||||
onServerChange={(server) => surface.replaceServer(ServerConnection.key(server))}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Show when={props.entry.connection} keyed>
|
||||
{(server) => (
|
||||
<SettingsServerDataScope server={server}>
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects
|
||||
server={server}
|
||||
active={surface.view().tab === "projects"}
|
||||
onOpenProject={(project) =>
|
||||
surface.openProject({
|
||||
server: props.entry.key,
|
||||
project: project.worktree,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={activeDirectory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={undefined} onBack={() => surface.select("providers")} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="settings-panel">
|
||||
<SettingsModels active={surface.view().tab === "models"} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<SettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
</Tabs.Content>
|
||||
</SettingsServerDataScope>
|
||||
)}
|
||||
</Show>
|
||||
</SettingsNavigation>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectSettings(props: { server: ServerConnection.Any; project: LocalProject }) {
|
||||
const language = useLanguage()
|
||||
const surface = useSettingsSurface()
|
||||
const activeDirectory = useSettingsDirectory(() => props.server)
|
||||
const 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 (
|
||||
<SettingsServerDataScope server={props.server} directory={props.project.worktree}>
|
||||
<LocationProvider directory={props.project.worktree}>
|
||||
<SettingsNavigation
|
||||
value={surface.view().tab}
|
||||
groups={groups}
|
||||
backLabel={language.t("settings.backToProjects")}
|
||||
onBack={() => surface.back()}
|
||||
onChange={(value) => surface.select(value)}
|
||||
>
|
||||
<Tabs.Content value="general" class="settings-panel">
|
||||
<SettingsProjectGeneral
|
||||
server={props.server}
|
||||
project={props.project}
|
||||
onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces projectID={props.project.id} activeDirectory={activeDirectory()} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="extensions" class="settings-panel">
|
||||
<ProjectSettingsExtensions subtab={surface.view().subtab} onSubtab={(value) => surface.subtab(value)} />
|
||||
</Tabs.Content>
|
||||
</SettingsNavigation>
|
||||
</LocationProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
function connectionFor(list: readonly ServerConnection.Any[], key: string | undefined) {
|
||||
return list.find((item) => ServerConnection.key(item) === key)
|
||||
}
|
||||
|
||||
function useSettingsDirectory(server: Accessor<ServerConnection.Any | undefined>) {
|
||||
const surface = useSettingsSurface()
|
||||
const tabs = useTabs()
|
||||
const serverCtx = useServerCtx(server)
|
||||
return createMemo(() => {
|
||||
const current = server()
|
||||
if (!current) return undefined
|
||||
const key = ServerConnection.key(current)
|
||||
const route = surface.route()
|
||||
if (route.type === "session" && route.server === key)
|
||||
return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
if (route.type !== "draft") return undefined
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" && draft.server === key ? draft.directory : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,83 +1,8 @@
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { batch, createEffect, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
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"
|
||||
}
|
||||
) & {
|
||||
target?: string
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
searchActivation?: number
|
||||
}
|
||||
|
||||
const rootTabs: Record<SettingsRootTab, true> = {
|
||||
general: true,
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
servers: true,
|
||||
experimental: true,
|
||||
about: true,
|
||||
}
|
||||
const serverTabs: Record<SettingsServerTab, true> = {
|
||||
general: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
models: true,
|
||||
extensions: true,
|
||||
}
|
||||
const projectTabs: Record<SettingsProjectTab, true> = {
|
||||
general: true,
|
||||
workspaces: true,
|
||||
extensions: true,
|
||||
}
|
||||
|
||||
function isRootTab(value: string): value is SettingsRootTab {
|
||||
return value in rootTabs
|
||||
}
|
||||
|
||||
function isServerTab(value: string): value is SettingsServerTab {
|
||||
return value in serverTabs
|
||||
}
|
||||
|
||||
function isProjectTab(value: string): value is SettingsProjectTab {
|
||||
return value in projectTabs
|
||||
}
|
||||
|
||||
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
|
||||
name: "SettingsSurface",
|
||||
@@ -86,39 +11,18 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const command = useCommand()
|
||||
const servers = useSettingsServers()
|
||||
const location = useLocation<{
|
||||
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; view: SettingsView }
|
||||
settings?: { route: Exclude<LayoutRoute, { type: "settings" }>; tab: string }
|
||||
}>()
|
||||
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" }
|
||||
const [search, setSearch] = createStore({
|
||||
query: "",
|
||||
origin: undefined as SettingsView | undefined,
|
||||
selected: "",
|
||||
highlighted: "",
|
||||
scrollTop: 0,
|
||||
activation: 0,
|
||||
expanded: true,
|
||||
})
|
||||
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,
|
||||
(value) => {
|
||||
if (value) return
|
||||
setSearch({ query: "", origin: undefined, selected: "", highlighted: "", scrollTop: 0, expanded: true })
|
||||
if (focus?.isConnected) focus.focus({ preventScroll: true })
|
||||
focus = undefined
|
||||
},
|
||||
@@ -129,85 +33,16 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr
|
||||
return {
|
||||
active: open,
|
||||
route: source,
|
||||
view,
|
||||
search: {
|
||||
state: search,
|
||||
input(query: string) {
|
||||
if (!search.query.trim() && query.trim()) setSearch("origin", { ...view(), target: undefined })
|
||||
setSearch({ query, highlighted: "", scrollTop: 0, expanded: true })
|
||||
if (!query.trim()) setSearch({ selected: "", origin: undefined })
|
||||
},
|
||||
expand() {
|
||||
setSearch("expanded", true)
|
||||
},
|
||||
highlight(id: string) {
|
||||
setSearch("highlighted", id)
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
setSearch("scrollTop", scrollTop)
|
||||
},
|
||||
open(destination: SettingsView, id: string) {
|
||||
batch(() => {
|
||||
show({ ...destination, searchActivation: search.activation + 1 }, true)
|
||||
setSearch({ selected: id, highlighted: id, expanded: false, activation: search.activation + 1 })
|
||||
})
|
||||
},
|
||||
clear() {
|
||||
setSearch({ query: "", selected: "", highlighted: "", scrollTop: 0, origin: undefined, expanded: true })
|
||||
},
|
||||
back() {
|
||||
if (!search.query.trim() || !search.selected || !search.origin) return false
|
||||
show(search.origin, true)
|
||||
setSearch({ selected: "", expanded: true })
|
||||
return true
|
||||
},
|
||||
},
|
||||
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, target: undefined, subtab: undefined }, true)
|
||||
},
|
||||
subtab(subtab: SettingsView["subtab"]) {
|
||||
show({ ...view(), subtab, target: undefined }, true)
|
||||
},
|
||||
back() {
|
||||
const current = view()
|
||||
if (current.type === "root") {
|
||||
command.trigger("common.goBack")
|
||||
return
|
||||
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
|
||||
}
|
||||
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)
|
||||
navigate("/settings", {
|
||||
replace: open(),
|
||||
state: { settings: { route: route.type === "settings" ? source() : route, tab } },
|
||||
})
|
||||
},
|
||||
close() {
|
||||
if (open()) command.trigger("common.goBack")
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogFooter } from "@opencode/ui/dialog"
|
||||
import { Field } from "@opencode/ui/field"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Textarea } from "@opencode/ui/textarea"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { LocationProvider } from "@/workspaces/location"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { createEditProjectModel } from "./project-model"
|
||||
import { ProjectSettingsExtensions } from "./project-extensions"
|
||||
import { SettingsServerDataScope } from "@/settings/server-scope"
|
||||
import "@/settings/settings.css"
|
||||
import "./project-dialog.css"
|
||||
|
||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
return (
|
||||
<SettingsServerDataScope server={props.server}>
|
||||
<LocationProvider directory={props.project.worktree}>
|
||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||
</LocationProvider>
|
||||
</SettingsServerDataScope>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
const projectName = () => displayName(props.project)
|
||||
const [tab, setTab] = createSignal("general")
|
||||
|
||||
const Footer = () => (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" variant="settings" class="project-settings-dialog">
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="project-settings-v2"
|
||||
>
|
||||
<Tabs.List>
|
||||
<div class="project-settings-nav">
|
||||
<Tabs.Trigger value="general">
|
||||
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
|
||||
<span class="truncate">{projectName()}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="scripts">
|
||||
<Icon name="code" size="small" />
|
||||
{language.t("project.settings.scripts")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="extensions">
|
||||
<Icon name="extensions" size="small" />
|
||||
{language.t("settings.tab.extensions")}
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="project-settings-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-form">
|
||||
<div class="project-settings-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("dialog.project.edit.title")}</h2>
|
||||
<span>{language.t("project.settings.general.description")}</span>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||
<TextInput
|
||||
autofocus
|
||||
appearance="large"
|
||||
class="!w-full"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.icon")}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||
}}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={model.drop}
|
||||
onDragOver={model.dragOver}
|
||||
onDragLeave={model.dragLeave}
|
||||
onClick={model.iconClick}
|
||||
>
|
||||
<ProjectIcon
|
||||
project={props.project}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
icon={{
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
}}
|
||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||
classList={{
|
||||
"opacity-100": model.store.iconHover,
|
||||
"opacity-0": !model.store.iconHover,
|
||||
}}
|
||||
>
|
||||
<Icon name={model.store.iconOverride ? "close" : "share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => model.setIconInput(element)}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={model.inputChange}
|
||||
/>
|
||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
|
||||
{language.t("dialog.project.edit.color")}
|
||||
</div>
|
||||
<div class="-ml-1 flex gap-1.5">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||
getProjectAvatarVariant(model.store.color) === color,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||
model.setStore(
|
||||
"color",
|
||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
variant={getProjectAvatarVariant(color)}
|
||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="scripts" class="project-settings-panel">
|
||||
<form onSubmit={model.submit} class="project-settings-form">
|
||||
<div class="project-settings-scroll">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("project.settings.scripts")}</h2>
|
||||
<span>{language.t("project.settings.scripts.description")}</span>
|
||||
</div>
|
||||
<Field>
|
||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||
<Textarea
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={5}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Footer />
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="extensions" class="project-settings-panel">
|
||||
<ProjectSettingsExtensions />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,7 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useMcpToggle } from "@/providers/connect/mcp"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -21,9 +9,6 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { pluginLabels } from "@/providers/catalog/plugin"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { configuredLanguageServers } from "./project-lsp"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "./project.css"
|
||||
|
||||
type SkillItem = {
|
||||
name: string
|
||||
@@ -33,54 +18,23 @@ type SkillItem = {
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: JSX.Element }> = (props) => (
|
||||
<SettingsList variant="catalog">{props.children}</SettingsList>
|
||||
<div class="project-settings-extension-card">{props.children}</div>
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
icon: "mcp" | "cube" | "post-skill"
|
||||
name: string
|
||||
description?: JSX.Element
|
||||
children?: JSX.Element
|
||||
}> = (props) => (
|
||||
<div class="settings-extension-row project-settings-extension-row">
|
||||
<div class="settings-extension-lead">
|
||||
<div class="project-settings-extension-row">
|
||||
<div class="project-settings-extension-row-main">
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<div class="project-settings-extension-row-copy">
|
||||
<span class="project-settings-extension-row-name settings-extension-name">{props.name}</span>
|
||||
<Show when={props.description}>
|
||||
<span class="project-settings-extension-row-description">{props.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ProjectSectionHeader: Component<{
|
||||
kind: "mcps" | "plugins" | "skills"
|
||||
empty: boolean
|
||||
action: JSX.Element
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="project-settings-extension-section-header">
|
||||
<div class="project-settings-extension-section-copy">
|
||||
<span>
|
||||
{props.empty
|
||||
? language.t(`project.settings.extensions.empty.${props.kind}.title`)
|
||||
: language.t("project.settings.extensions.added")}
|
||||
</span>
|
||||
<Show when={props.empty}>
|
||||
<span class="project-settings-extension-empty-description">
|
||||
{language.t(`project.settings.extensions.empty.${props.kind}.description`)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
{props.action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SharedSection: Component<{
|
||||
count: number
|
||||
children: JSX.Element
|
||||
@@ -92,21 +46,13 @@ const SharedSection: Component<{
|
||||
<div class="project-settings-shared">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger project-settings-shared-trigger"
|
||||
class="project-settings-shared-trigger"
|
||||
aria-expanded={open()}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="fill-triangle-down"
|
||||
size="small"
|
||||
classList={{ "project-settings-shared-chevron": true, open: open() }}
|
||||
/>
|
||||
</span>
|
||||
<span class="project-settings-shared-label">
|
||||
<span class="settings-models-group-title">{language.t("project.settings.extensions.shared")}</span>
|
||||
<span class="project-settings-shared-count">{props.count}</span>
|
||||
</span>
|
||||
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
||||
<span>{language.t("project.settings.extensions.shared")}</span>
|
||||
<span class="project-settings-shared-count">{props.count}</span>
|
||||
</button>
|
||||
<Show when={open()}>
|
||||
<ExtensionCard>{props.children}</ExtensionCard>
|
||||
@@ -116,99 +62,7 @@ const SharedSection: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
const ProjectLanguageServers: Component = () => {
|
||||
const language = useLanguage()
|
||||
const server = useServerSDK()
|
||||
const location = useWorkspaceLocation()
|
||||
const config = useQuery(() => ({
|
||||
queryKey: [server.scope, "settings-project-language-servers", location().directory],
|
||||
enabled: server.connection.status() === "connected",
|
||||
queryFn: () => server.api.config.get({ location: { directory: location().directory } }),
|
||||
}))
|
||||
const configured = createMemo(() =>
|
||||
configuredLanguageServers(config.isPending || config.isError ? [] : (config.data ?? [])),
|
||||
)
|
||||
const empty = () => !config.isPending && !config.isError && configured().servers.length === 0
|
||||
onCleanup(
|
||||
server.event.on("config.updated", (event) => {
|
||||
if (event.location && event.location.directory !== location().directory) return
|
||||
void config.refetch()
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<div class="project-settings-extension-section-copy">
|
||||
<span>
|
||||
{language.t(
|
||||
empty()
|
||||
? configured().disabled
|
||||
? "project.settings.extensions.lsp.disabled.title"
|
||||
: "project.settings.extensions.lsp.empty.title"
|
||||
: "project.settings.extensions.lsp.configured",
|
||||
)}
|
||||
</span>
|
||||
<Show when={empty()}>
|
||||
<span class="project-settings-extension-empty-description">
|
||||
{language.t(
|
||||
configured().disabled
|
||||
? "project.settings.extensions.lsp.disabled.description"
|
||||
: "project.settings.extensions.lsp.empty.description",
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show
|
||||
when={!config.isPending}
|
||||
fallback={<p class="project-settings-extension-empty-description">{language.t("common.loading")}</p>}
|
||||
>
|
||||
<Show
|
||||
when={!config.isError}
|
||||
fallback={
|
||||
<div class="project-settings-extension-section-copy" role="status">
|
||||
<span class="project-settings-extension-empty-description">
|
||||
{language.t("project.settings.extensions.lsp.loadFailed")}
|
||||
</span>
|
||||
<Button variant="ghost-muted" onClick={() => void config.refetch()}>
|
||||
{language.t("project.settings.extensions.lsp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={configured().servers.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={configured().servers}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name}
|
||||
description={item.extensions.length ? <bdi dir="ltr">{item.extensions.join(", ")}</bdi> : undefined}
|
||||
>
|
||||
<span class="project-settings-extension-row-status">
|
||||
{language.t(
|
||||
item.disabled
|
||||
? "project.settings.extensions.lsp.status.disabled"
|
||||
: "project.settings.extensions.lsp.status.enabled",
|
||||
)}
|
||||
</span>
|
||||
</ExtensionRow>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProjectSettingsExtensions: Component<{
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
onSubtab: (value: "mcps" | "plugins" | "skills" | "lsps") => void
|
||||
}> = (props) => {
|
||||
export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useWorkspaceLocation()
|
||||
@@ -291,84 +145,61 @@ export const ProjectSettingsExtensions: Component<{
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("project.settings.extensions.description")}
|
||||
</span>
|
||||
<div class="project-settings-extensions">
|
||||
<div class="project-settings-page-header">
|
||||
<h2>{language.t("settings.tab.extensions")}</h2>
|
||||
<span>{language.t("project.settings.extensions.description")}</span>
|
||||
</div>
|
||||
|
||||
<Tabs variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="mcps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
<Show when={projectMcpNames().length > 0}>
|
||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.subtab ?? "mcps"}
|
||||
onChange={(value) => {
|
||||
if (value === "mcps" || value === "plugins" || value === "skills" || value === "lsps") props.onSubtab(value)
|
||||
}}
|
||||
class="project-settings-extension-tabs settings-subtabs"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="mcps">
|
||||
<div class="project-settings-extension-section">
|
||||
<ProjectSectionHeader
|
||||
kind="mcps"
|
||||
empty={projectMcpNames().length === 0}
|
||||
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
|
||||
/>
|
||||
<Show when={projectMcpNames().length > 0}>
|
||||
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Show when={projectPlugins().length > 0}>
|
||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="project-settings-extension-section">
|
||||
<ProjectSectionHeader
|
||||
kind="plugins"
|
||||
empty={projectPlugins().length === 0}
|
||||
action={<span>{language.t("settings.extensions.manageConfig")}</span>}
|
||||
/>
|
||||
<Show when={projectPlugins().length > 0}>
|
||||
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
||||
<Tabs.Content value="skills">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.added")}</span>
|
||||
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="skills">
|
||||
<div class="project-settings-extension-section">
|
||||
<ProjectSectionHeader
|
||||
kind="skills"
|
||||
empty={projectSkills().length === 0}
|
||||
action={
|
||||
<ExternalLink class="settings-extension-link" href="https://opencode.ai/docs/skills/">
|
||||
{language.t("settings.extensions.addSkills")}
|
||||
</ExternalLink>
|
||||
}
|
||||
/>
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="lsps">
|
||||
<ProjectLanguageServers />
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ConfigEntry } from "@opencode/client/promise"
|
||||
import { configuredLanguageServers } from "./project-lsp"
|
||||
|
||||
const global: ConfigEntry = {
|
||||
type: "document",
|
||||
path: "/config/opencode.json",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe("configuredLanguageServers", () => {
|
||||
test("merges inherited entries and project overrides by name", () => {
|
||||
expect(
|
||||
configuredLanguageServers([
|
||||
global,
|
||||
{ type: "directory", path: "/project/.opencode" },
|
||||
{
|
||||
type: "document",
|
||||
path: "/project/opencode.jsonc",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
eslint: { command: ["eslint-lsp"], disabled: false, extensions: [".js"] },
|
||||
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
disabled: false,
|
||||
servers: [
|
||||
{ name: "eslint", disabled: false, extensions: [".js"] },
|
||||
{ name: "rust", disabled: false, extensions: [".rs"] },
|
||||
{ name: "typescript", disabled: true, extensions: [".ts", ".tsx"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent servers for omitted or boolean-only configuration", () => {
|
||||
expect(configuredLanguageServers([])).toEqual({ disabled: false, servers: [] })
|
||||
expect(configuredLanguageServers([global, { type: "document", info: { lsp: false } }])).toEqual({
|
||||
disabled: true,
|
||||
servers: [],
|
||||
})
|
||||
expect(configuredLanguageServers([global, { type: "document", info: { lsp: true } }])).toEqual({
|
||||
disabled: false,
|
||||
servers: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("allows named configuration after a disabled parent without reviving earlier entries", () => {
|
||||
expect(
|
||||
configuredLanguageServers([
|
||||
global,
|
||||
{ type: "document", info: { lsp: false } },
|
||||
{ type: "document", info: { lsp: { custom: { command: ["custom-lsp"] } } } },
|
||||
]),
|
||||
).toEqual({ disabled: false, servers: [{ name: "custom", disabled: false, extensions: [] }] })
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ConfigEntry } from "@opencode/client/promise"
|
||||
|
||||
type ConfiguredServer = { name: string; disabled: boolean; extensions: readonly string[] }
|
||||
|
||||
export function configuredLanguageServers(entries: readonly ConfigEntry[]) {
|
||||
const state = entries.reduce(
|
||||
(state, entry) => {
|
||||
if (entry.type !== "document" || entry.info.lsp === undefined) return state
|
||||
const config = entry.info.lsp
|
||||
// A boolean replaces the object form, so earlier named entries no longer apply.
|
||||
if (typeof config === "boolean") return { disabled: !config, servers: new Map<string, ConfiguredServer>() }
|
||||
Object.entries(config).forEach(([name, server]) => {
|
||||
const previous = state.servers.get(name)
|
||||
state.servers.set(name, {
|
||||
name,
|
||||
disabled: server.disabled ?? previous?.disabled ?? false,
|
||||
extensions: ("extensions" in server ? server.extensions : undefined) ?? previous?.extensions ?? [],
|
||||
})
|
||||
})
|
||||
return { disabled: false, servers: state.servers }
|
||||
},
|
||||
{ disabled: false, servers: new Map<string, ConfiguredServer>() },
|
||||
)
|
||||
return {
|
||||
disabled: state.disabled,
|
||||
servers: [...state.servers.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { ProjectUpdateInput } from "@opencode/client/promise"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
type ProjectPatch = Pick<ProjectUpdateInput, "name" | "icon" | "commands">
|
||||
|
||||
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const global = useGlobal()
|
||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||
@@ -23,62 +20,8 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
startup: props.project.commands?.start ?? "",
|
||||
dragOver: false,
|
||||
iconHover: false,
|
||||
saving: 0,
|
||||
})
|
||||
const saved = {
|
||||
name: props.project.name ?? "",
|
||||
startup: store.startup.trim(),
|
||||
color: store.color,
|
||||
iconOverride: store.iconOverride,
|
||||
}
|
||||
let iconInput: HTMLInputElement | undefined
|
||||
let queue = Promise.resolve()
|
||||
|
||||
const persist = (patch: ProjectPatch, complete: () => void) => {
|
||||
setStore("saving", (value) => value + 1)
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, ...patch })
|
||||
serverCtx().sync.project.update(project)
|
||||
return
|
||||
}
|
||||
serverCtx().sync.project.meta(props.project.worktree, patch)
|
||||
})
|
||||
.then(complete)
|
||||
.catch((error: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
})
|
||||
.finally(() => setStore("saving", (value) => value - 1))
|
||||
}
|
||||
|
||||
const saveName = () => {
|
||||
const value = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
if (value === saved.name) return
|
||||
persist({ name: value }, () => {
|
||||
saved.name = value
|
||||
})
|
||||
}
|
||||
|
||||
const saveStartup = () => {
|
||||
const value = store.startup.trim()
|
||||
if (value === saved.startup) return
|
||||
persist({ commands: { start: value } }, () => {
|
||||
saved.startup = value
|
||||
})
|
||||
}
|
||||
|
||||
const saveIcon = (color = store.color, override = store.iconOverride) => {
|
||||
if (color === saved.color && override === saved.iconOverride) return
|
||||
persist({ icon: { color: color ?? "", override: override ?? "" } }, () => {
|
||||
saved.color = color
|
||||
saved.iconOverride = override
|
||||
})
|
||||
}
|
||||
|
||||
function selectFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) return
|
||||
@@ -88,46 +31,85 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
||||
if (typeof result !== "string") return
|
||||
setStore("iconOverride", result)
|
||||
setStore("iconHover", false)
|
||||
saveIcon(store.color, result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function dragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
}
|
||||
|
||||
function dragLeave() {
|
||||
setStore("dragOver", false)
|
||||
}
|
||||
|
||||
function inputChange(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||
if (file) selectFile(file)
|
||||
}
|
||||
|
||||
function iconClick() {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
setStore("iconOverride", "")
|
||||
return
|
||||
}
|
||||
iconInput?.click()
|
||||
}
|
||||
|
||||
const save = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||
const start = store.startup.trim()
|
||||
|
||||
if (props.project.id && props.project.id !== "global") {
|
||||
const project = await serverCtx().sdk.api.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
|
||||
commands: { start },
|
||||
})
|
||||
serverCtx().sync.project.update(project)
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
|
||||
serverCtx().sync.project.meta(props.project.worktree, {
|
||||
name,
|
||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||
commands: { start: start || undefined },
|
||||
})
|
||||
dialog.close()
|
||||
},
|
||||
}))
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault()
|
||||
if (save.isPending) return
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
setStore,
|
||||
folderName,
|
||||
defaultName,
|
||||
saveName,
|
||||
saveStartup,
|
||||
setColor(value: string | undefined) {
|
||||
setStore("color", value)
|
||||
saveIcon(value, store.iconOverride)
|
||||
},
|
||||
drop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", false)
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) selectFile(file)
|
||||
},
|
||||
dragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
setStore("dragOver", true)
|
||||
},
|
||||
dragLeave() {
|
||||
setStore("dragOver", false)
|
||||
},
|
||||
inputChange(input: HTMLInputElement) {
|
||||
const file = input.files?.[0]
|
||||
if (file) selectFile(file)
|
||||
},
|
||||
iconClick() {
|
||||
if (store.iconOverride && store.iconHover) {
|
||||
setStore("iconOverride", "")
|
||||
saveIcon(store.color, "")
|
||||
return
|
||||
}
|
||||
iconInput?.click()
|
||||
save,
|
||||
submit,
|
||||
drop,
|
||||
dragOver,
|
||||
dragLeave,
|
||||
inputChange,
|
||||
iconClick,
|
||||
close() {
|
||||
dialog.close()
|
||||
},
|
||||
setIconInput(input: HTMLInputElement) {
|
||||
iconInput = input
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
.project-settings-extension-tabs {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-settings-extension-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.project-settings-extension-section-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.project-settings-extension-section-header > :last-child {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
.project-settings-extension-empty-description {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.project-settings-extension-row-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
}
|
||||
|
||||
.project-settings-extension-row-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-description {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.project-settings-extension-row-status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.project-settings-shared-trigger {
|
||||
width: fit-content;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.project-settings-shared-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron {
|
||||
color: var(--v2-icon-icon-muted);
|
||||
transition: transform 120ms ease;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.project-settings-shared-chevron.open {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
[dir="rtl"] .project-settings-shared-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[dir="rtl"] .project-settings-shared-chevron.open {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.project-settings-shared-count {
|
||||
display: inline-flex;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.project-settings-name {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.project-settings-server-link {
|
||||
width: fit-content;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-settings-server-link:hover,
|
||||
.project-settings-server-link:focus-visible {
|
||||
color: var(--v2-text-text-base);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-settings-server-link:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.project-settings-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
outline: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-settings-icon--active,
|
||||
.project-settings-icon:focus-visible {
|
||||
outline-color: var(--v2-border-border-focus);
|
||||
}
|
||||
|
||||
.project-settings-icon-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--v2-background-bg-contrast) 80%, transparent);
|
||||
color: var(--v2-icon-icon-contrast);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.project-settings-icon-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.project-settings-colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-settings-color {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
transition: box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.project-settings-color:focus-visible,
|
||||
.project-settings-color--selected {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--v2-background-bg-layer-01),
|
||||
0 0 0 4px var(--v2-border-border-focus);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.project-settings-color:not(.project-settings-color--selected):hover {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--v2-background-bg-layer-01),
|
||||
0 0 0 4px var(--v2-border-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.project-settings-color-check {
|
||||
position: absolute;
|
||||
color: var(--v2-icon-icon-contrast);
|
||||
}
|
||||
|
||||
.project-settings-startup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-block: 20px;
|
||||
}
|
||||
|
||||
.project-settings-startup-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-settings-startup-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.project-settings-startup-description,
|
||||
.project-settings-startup-hint {
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.project-settings-startup-hint code {
|
||||
padding-inline: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-settings-icon-overlay,
|
||||
.project-settings-color,
|
||||
.project-settings-shared-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode/ui/project-avatar"
|
||||
import { Textarea } from "@opencode/ui/textarea"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { createEditProjectModel } from "./project-model"
|
||||
import "./project.css"
|
||||
|
||||
export const SettingsProjectGeneral: Component<{
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
onOpenServer: () => void
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const model = createEditProjectModel(props)
|
||||
const servers = useSettingsServers()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="settings-tab-title truncate">
|
||||
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
|
||||
</h2>
|
||||
<Show when={servers().length > 1}>
|
||||
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
|
||||
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("project.settings.name.title")}
|
||||
description={language.t("project.settings.name.description")}
|
||||
>
|
||||
<div class="project-settings-name">
|
||||
<TextInput
|
||||
data-action="settings-project-name"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={model.store.name}
|
||||
placeholder={model.folderName()}
|
||||
aria-label={language.t("project.settings.name.title")}
|
||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||
onBlur={model.saveName}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("dialog.project.edit.icon")}
|
||||
description={language.t("project.settings.icon.description")}
|
||||
>
|
||||
<button
|
||||
data-action="settings-project-icon"
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||
class="project-settings-icon"
|
||||
classList={{ "project-settings-icon--active": model.store.dragOver }}
|
||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||
onDrop={(event) => model.drop(event)}
|
||||
onDragOver={(event) => model.dragOver(event)}
|
||||
onDragLeave={() => model.dragLeave()}
|
||||
onClick={() => model.iconClick()}
|
||||
>
|
||||
<ProjectIcon
|
||||
project={props.project}
|
||||
fallback={model.store.name || model.defaultName()}
|
||||
icon={{
|
||||
color: model.store.color,
|
||||
url: props.project.icon?.url,
|
||||
override: model.store.iconOverride,
|
||||
}}
|
||||
class="!size-8 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
|
||||
/>
|
||||
<span classList={{ "project-settings-icon-overlay": true, visible: model.store.iconHover }}>
|
||||
<Icon name={model.store.iconOverride ? "close" : "share"} />
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={(element) => model.setIconInput(element)}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onChange={(event) => model.inputChange(event.currentTarget)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={!model.store.iconOverride}>
|
||||
<SettingsRow
|
||||
title={language.t("dialog.project.edit.color")}
|
||||
description={language.t("project.settings.color.description")}
|
||||
>
|
||||
<div class="project-settings-colors" data-action="settings-project-color">
|
||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||
{(color) => {
|
||||
const selected = () => getProjectAvatarVariant(model.store.color) === color
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||
aria-pressed={selected()}
|
||||
class="project-settings-color"
|
||||
classList={{ "project-settings-color--selected": selected() }}
|
||||
onClick={() => model.setColor(selected() ? undefined : color)}
|
||||
>
|
||||
<ProjectAvatar
|
||||
fallback=""
|
||||
variant={color}
|
||||
class="!size-5 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||
/>
|
||||
<Show when={selected()}>
|
||||
<Icon name="check" size="small" class="project-settings-color-check" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<div class="project-settings-startup">
|
||||
<div class="project-settings-startup-copy">
|
||||
<span class="project-settings-startup-title">{language.t("dialog.project.edit.worktree.startup")}</span>
|
||||
<span class="project-settings-startup-description">
|
||||
{language.t("project.settings.worktree.startup.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||
rows={5}
|
||||
value={model.store.startup}
|
||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||
aria-label={language.t("dialog.project.edit.worktree.startup")}
|
||||
spellcheck={false}
|
||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||
onBlur={model.saveStartup}
|
||||
/>
|
||||
<div class="project-settings-startup-hint flex flex-col">
|
||||
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.base"))}</span>
|
||||
<span>{inlineVariables(language.t("project.settings.worktree.startup.hint.new"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function inlineVariables(text: string) {
|
||||
return text
|
||||
.split(/(\$[A-Z][A-Z0-9_]*)/g)
|
||||
.map((part, index) => (index % 2 === 0 ? part : <code dir="ltr">{part}</code>))
|
||||
}
|
||||
@@ -1,112 +1,146 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { ServerConnection, serverName } from "@/runtime/server/registry"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
import { settingsProjects } from "../servers/inventory"
|
||||
import { InlineServerSelect } from "@/settings/server-select"
|
||||
import { DialogEditProject } from "./project-dialog"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsProjects: Component<{
|
||||
server: ServerConnection.Any
|
||||
active?: boolean
|
||||
autofocus?: boolean
|
||||
onOpenProject: (project: LocalProject) => void
|
||||
}> = (props) => {
|
||||
export const SettingsProjects: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const global = useGlobal()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
let search: HTMLInputElement | undefined
|
||||
const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server)))
|
||||
const searchable = createMemo(() => projects().length > 7)
|
||||
const filtered = createMemo(() => {
|
||||
const query = searchable() ? store.filter.trim().toLowerCase() : ""
|
||||
return query ? projects().filter((project) => displayName(project).toLowerCase().includes(query)) : projects()
|
||||
const [allServers, setAllServers] = createSignal(true)
|
||||
const selected = global.settings.server.selected
|
||||
const multiple = createMemo(() => global.servers.list().length > 1)
|
||||
const projects = createMemo(() => {
|
||||
const server = selected()
|
||||
if (!server) return []
|
||||
return global.ensureServerCtx(server).projects.list()
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => (props.active ?? true) && searchable(),
|
||||
(active) => {
|
||||
if (!active) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (props.active !== false && props.autofocus !== false && search?.isConnected)
|
||||
search.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => cancelAnimationFrame(frame))
|
||||
},
|
||||
),
|
||||
|
||||
type ProjectItem = ReturnType<typeof projects>[number]
|
||||
|
||||
const groups = createMemo(() =>
|
||||
global.servers
|
||||
.list()
|
||||
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
||||
.filter((group) => group.projects.length > 0),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!searchable()) setStore("filter", "")
|
||||
})
|
||||
|
||||
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
||||
if (!server) return
|
||||
dialog.push(() => <DialogEditProject project={project} server={server} />)
|
||||
}
|
||||
|
||||
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
||||
const name = () => displayName(props.project)
|
||||
return (
|
||||
<div
|
||||
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
|
||||
onClick={() => openProjectSettings(props.project, props.server)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={props.project} class="shrink-0" />
|
||||
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openProjectSettings(props.project, props.server)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header" classList={{ "settings-tab-header--stacked": searchable() }}>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={searchable()}>
|
||||
<div class="settings-tab-search">
|
||||
<TextInput
|
||||
ref={search}
|
||||
type="search"
|
||||
appearance="base"
|
||||
value={store.filter}
|
||||
onInput={(event) => setStore("filter", event.currentTarget.value)}
|
||||
placeholder={language.t("settings.projects.search.placeholder")}
|
||||
aria-label={language.t("settings.projects.search.placeholder")}
|
||||
showClearButton={!!store.filter}
|
||||
onClearClick={() => {
|
||||
setStore("filter", "")
|
||||
search?.focus({ preventScroll: true })
|
||||
<Show when={multiple()}>
|
||||
<InlineServerSelect
|
||||
all={{
|
||||
label: language.t("settings.projects.server.all"),
|
||||
selected: allServers,
|
||||
onSelect: () => setAllServers(true),
|
||||
}}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onServerSelect={() => setAllServers(false)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
when={allServers()}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<Show
|
||||
when={projects().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={selected()} keyed>
|
||||
{(server) => (
|
||||
<div class="settings-section">
|
||||
<Show when={multiple()}>
|
||||
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<For each={filtered()}>
|
||||
{(project) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={displayName(project)}
|
||||
class="group mx-px flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-[background-color] hover:bg-v2-background-bg-layer-01 text-start"
|
||||
onClick={() => props.onOpenProject(project)}
|
||||
>
|
||||
<span class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<ProjectIcon project={project} class="shrink-0" />
|
||||
<bdi class="text-13-medium text-v2-text-text-base truncate">{displayName(project)}</bdi>
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-right"
|
||||
size="small"
|
||||
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={
|
||||
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||
{language.t("settings.projects.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="settings-section">
|
||||
<Show when={multiple()}>
|
||||
<h3 class="settings-section-title">
|
||||
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||
</h3>
|
||||
</Show>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<For each={group.projects}>
|
||||
{(project) => <ProjectRow project={project} server={group.server} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { queryOptions, useQueryClient, type QueryClient } from "@tanstack/solid-query"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerCtx, type ServerCtx } from "@/runtime/server/runtime"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
function workspaceProjectsQuery(sdk: ServerSDK) {
|
||||
return queryOptions({
|
||||
queryKey: [sdk.scope, "settings-workspace-project-metadata"],
|
||||
queryFn: () => sdk.api.project.list(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient, projectID?: string) {
|
||||
return queryOptions({
|
||||
queryKey: [context.sdk.scope, "settings-workspace-inventory", projectID ?? null],
|
||||
queryFn: async () =>
|
||||
Promise.all(
|
||||
(await client.fetchQuery(workspaceProjectsQuery(context.sdk)))
|
||||
.filter((project) => projectID === undefined || project.id === projectID)
|
||||
.map(async (project) => {
|
||||
const worktrees = (await context.sync.worktrees.load(project.canonical)) ?? [
|
||||
{ directory: project.canonical },
|
||||
...project.sandboxes.map((directory) => ({ directory })),
|
||||
]
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspacesPrefetch(
|
||||
server: Accessor<ServerConnection.Any | undefined>,
|
||||
projectID?: Accessor<string | undefined>,
|
||||
) {
|
||||
const client = useQueryClient()
|
||||
const context = useServerCtx(server)
|
||||
return () => {
|
||||
const current = context()
|
||||
if (!current || current.sdk.connection.status() !== "connected") return
|
||||
const project = projectID?.()
|
||||
if (project) {
|
||||
void client.prefetchQuery(workspaceInventoryQuery(current, client, project))
|
||||
return
|
||||
}
|
||||
// Server-level hover warms metadata without booting every project's Location.
|
||||
void client.prefetchQuery(workspaceProjectsQuery(current.sdk))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -14,13 +14,14 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { getRelativeTime } from "@/shell/time"
|
||||
import { sessionLabel } from "@/session/title"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { InlineServerSelect } from "@/settings/server-select"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { clearWorkspaceTerminals } from "@/session/terminal/context"
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
} from "@/workspaces/paths"
|
||||
import { listAllSessions } from "@/session/list"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { workspaceInventoryQuery } from "./queries"
|
||||
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type Workspace = {
|
||||
@@ -47,17 +48,13 @@ type Workspace = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspaces: Component<{
|
||||
activeDirectory?: string
|
||||
resetProjectFilter?: () => number
|
||||
projectID?: string
|
||||
}> = (props) => {
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (
|
||||
props,
|
||||
) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const serverSDK = server.ctx.sdk
|
||||
const queryClient = useQueryClient()
|
||||
const data = server.ctx.data
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
@@ -67,18 +64,23 @@ export const SettingsWorkspaces: Component<{
|
||||
removing: [] as string[],
|
||||
})
|
||||
createEffect(() => {
|
||||
if (props.projectID) {
|
||||
setStore("project", props.projectID)
|
||||
return
|
||||
}
|
||||
props.resetProjectFilter?.()
|
||||
props.resetProjectFilter()
|
||||
setStore("project", "all")
|
||||
})
|
||||
|
||||
const projectQuery = useQuery(() => ({
|
||||
...workspaceInventoryQuery(server.ctx, queryClient, props.projectID),
|
||||
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
|
||||
enabled: serverSDK.connection.status() === "connected",
|
||||
refetchOnMount: true,
|
||||
queryFn: async () =>
|
||||
Promise.all(
|
||||
(await serverSDK.api.project.list()).map(async (project) => {
|
||||
const worktrees = await serverSDK.api.worktree
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
|
||||
const workspaces = createMemo(() => workspaceInventory(inventory()))
|
||||
@@ -89,11 +91,7 @@ export const SettingsWorkspaces: Component<{
|
||||
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||
])
|
||||
const selectedProject = createMemo(() =>
|
||||
props.projectID
|
||||
? props.projectID
|
||||
: store.project === "all" || projects().some((project) => project.id === store.project)
|
||||
? store.project
|
||||
: "all",
|
||||
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||
)
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
@@ -127,10 +125,7 @@ export const SettingsWorkspaces: Component<{
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(() => {
|
||||
const sessions = mergeWorkspaceSessionInventory(
|
||||
sessionQuery.isPending ? [] : (sessionQuery.data ?? []),
|
||||
data.session.list(),
|
||||
)
|
||||
const sessions = sessionQuery.isPending ? [] : (sessionQuery.data ?? [])
|
||||
return new Map(
|
||||
workspaces().map((workspace) => [
|
||||
pathKey(workspace.directory),
|
||||
@@ -140,13 +135,13 @@ export const SettingsWorkspaces: Component<{
|
||||
})
|
||||
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
||||
const workspacesWithoutSessions = createMemo(() => {
|
||||
if (sessionQuery.isPending || sessionQuery.isError || sessionQuery.isPlaceholderData) return []
|
||||
if (sessionQuery.isPending || sessionQuery.isError) return []
|
||||
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
|
||||
})
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
if (!count && sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (!count && sessionQuery.isError) return language.t("common.requestFailed")
|
||||
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
|
||||
const project = projectName(workspace.project)
|
||||
const label = language.plural("settings.workspaces.sessions", count, {
|
||||
@@ -242,10 +237,7 @@ export const SettingsWorkspaces: Component<{
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.worktree),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })
|
||||
await projectQuery.refetch()
|
||||
} finally {
|
||||
setStore("deleting", (items) => items.filter((item) => item !== key))
|
||||
setStore("removing", (items) => items.filter((item) => item !== key))
|
||||
@@ -336,17 +328,18 @@ export const SettingsWorkspaces: Component<{
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.workspaces.description")}</span>
|
||||
</div>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-workspaces">
|
||||
<Show when={filtered().length > 0}>
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-section-title">
|
||||
<span class="settings-workspaces-count">
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={!props.projectID && projects().length > 1}>
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
|
||||
<span class="min-w-0 truncate">
|
||||
|
||||
@@ -31,7 +31,6 @@ test.each([
|
||||
)
|
||||
expect(config.webServer.reuseExistingServer).toBe(!built)
|
||||
expect(config.webServer.env).toEqual({
|
||||
VITE_OPENCODE_TEST_FIXTURES: "1",
|
||||
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
|
||||
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
|
||||
})
|
||||
|
||||
@@ -35,9 +35,6 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
...(process.env.VITE_OPENCODE_TEST_FIXTURES === "1"
|
||||
? { rolldownOptions: { input: ["index.html", "e2e/utils/settings-wsl.html"] } }
|
||||
: {}),
|
||||
assetsDir: "_assets",
|
||||
target: "esnext",
|
||||
sourcemap: true,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -360,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1139,6 +1148,7 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
@@ -2128,30 +2138,8 @@ export type ConfigGetInput = {
|
||||
export type ConfigGetOutput = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: ConfigGetInput) => Effect.Effect<ConfigGetOutput, E>
|
||||
|
||||
export type ConfigPreferencesOutput = Config.Preferences
|
||||
export type ConfigPreferencesOperation<E = never> = () => Effect.Effect<ConfigPreferencesOutput, E>
|
||||
|
||||
export type ConfigUpdatePreferencesInput = {
|
||||
readonly shell?: string | null | undefined
|
||||
readonly websearch?: false | { readonly provider: "random" | WebSearch.ID } | null | undefined
|
||||
}
|
||||
export type ConfigUpdatePreferencesOutput = Config.Preferences
|
||||
export type ConfigUpdatePreferencesOperation<E = never> = (
|
||||
input?: ConfigUpdatePreferencesInput,
|
||||
) => Effect.Effect<ConfigUpdatePreferencesOutput, E>
|
||||
|
||||
export type ConfigShellsOutput = ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly name: string
|
||||
readonly acceptable: boolean
|
||||
}>
|
||||
export type ConfigShellsOperation<E = never> = () => Effect.Effect<ConfigShellsOutput, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
readonly preferences: ConfigPreferencesOperation<E>
|
||||
readonly updatePreferences: ConfigUpdatePreferencesOperation<E>
|
||||
readonly shells: ConfigShellsOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
|
||||
@@ -68,6 +68,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -268,10 +270,6 @@ import type {
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
ConfigPreferencesOutput,
|
||||
ConfigUpdatePreferencesInput,
|
||||
ConfigUpdatePreferencesOutput,
|
||||
ConfigShellsOutput,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
@@ -596,6 +594,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -735,6 +744,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
@@ -1575,25 +1585,7 @@ const EndpointConfigGet = (raw: RawClient["server.config"]) => (input?: ConfigGe
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointConfigPreferences = (raw: RawClient["server.config"]) => () =>
|
||||
preserveEffect<ConfigPreferencesOutput>()(raw["config.preferences"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointConfigUpdatePreferences = (raw: RawClient["server.config"]) => (input?: ConfigUpdatePreferencesInput) =>
|
||||
preserveEffect<ConfigUpdatePreferencesOutput>()(
|
||||
raw["config.updatePreferences"]({ payload: { shell: input?.["shell"], websearch: input?.["websearch"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointConfigShells = (raw: RawClient["server.config"]) => () =>
|
||||
preserveEffect<ConfigShellsOutput>()(raw["config.shells"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({
|
||||
get: EndpointConfigGet(raw),
|
||||
preferences: EndpointConfigPreferences(raw),
|
||||
updatePreferences: EndpointConfigUpdatePreferences(raw),
|
||||
shells: EndpointConfigShells(raw),
|
||||
})
|
||||
const adaptGroupConfig = (raw: RawClient["server.config"]) => ({ get: EndpointConfigGet(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroupHealth(raw["server.health"]),
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -264,10 +266,6 @@ import type {
|
||||
WebsearchQueryOutput,
|
||||
ConfigGetInput,
|
||||
ConfigGetOutput,
|
||||
ConfigPreferencesOutput,
|
||||
ConfigUpdatePreferencesInput,
|
||||
ConfigUpdatePreferencesOutput,
|
||||
ConfigShellsOutput,
|
||||
} from "./types.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
@@ -846,6 +844,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
@@ -2192,34 +2202,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
preferences: (requestOptions?: RequestOptions) =>
|
||||
request<ConfigPreferencesOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/config/preferences`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
updatePreferences: (input?: ConfigUpdatePreferencesInput, requestOptions?: RequestOptions) =>
|
||||
request<ConfigUpdatePreferencesOutput>(
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/config/preferences`,
|
||||
body: { shell: input?.["shell"], websearch: input?.["websearch"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
shells: (requestOptions?: RequestOptions) =>
|
||||
request<ConfigShellsOutput>(
|
||||
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -431,10 +439,6 @@ export type WebSearchResult = { url: string; title?: string; content?: string; t
|
||||
|
||||
export type ConfigWorktree = { directory: string }
|
||||
|
||||
export type ConfigPreferences = { shell?: string; websearch?: false | { provider: "random" | (string & {}) } }
|
||||
|
||||
export type ConfigShellOption = { path: string; name: string; acceptable: boolean }
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
@@ -2198,6 +2202,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3156,6 +3161,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3461,6 +3473,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3766,6 +3785,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4255,6 +4281,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["messageID"]
|
||||
readonly to?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly messageID?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
@@ -6388,20 +6435,3 @@ export type ConfigGetInput = {
|
||||
}
|
||||
|
||||
export type ConfigGetOutput = Array<ConfigEntry>
|
||||
|
||||
export type ConfigPreferencesOutput = ConfigPreferences
|
||||
|
||||
export type ConfigUpdatePreferencesInput = {
|
||||
readonly shell?: {
|
||||
readonly shell?: string | null
|
||||
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
|
||||
}["shell"]
|
||||
readonly websearch?: {
|
||||
readonly shell?: string | null
|
||||
readonly websearch?: false | { readonly provider: "random" | (string & {}) } | null
|
||||
}["websearch"]
|
||||
}
|
||||
|
||||
export type ConfigUpdatePreferencesOutput = ConfigPreferences
|
||||
|
||||
export type ConfigShellsOutput = Array<ConfigShellOption>
|
||||
|
||||
@@ -1024,6 +1024,18 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { applyEdits, modify, type ParseError, parse } from "jsonc-parser"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
Directory,
|
||||
Document,
|
||||
Info,
|
||||
type Preferences,
|
||||
type PreferencesPatch,
|
||||
type Entry,
|
||||
Event,
|
||||
} from "@opencode/schema/config"
|
||||
@@ -43,10 +41,6 @@ export interface Interface {
|
||||
* source files they parse and rebuild their own state.
|
||||
*/
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
/** Returns preferences from the highest-precedence global config document. */
|
||||
readonly preferences?: () => Effect.Effect<Preferences, FSUtil.Error>
|
||||
/** Patches preferences in the highest-precedence global config document. */
|
||||
readonly updatePreferences?: (patch: PreferencesPatch) => Effect.Effect<Preferences, FSUtil.Error>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -86,19 +80,6 @@ export const testLayer = (initial: Entry[] = []) =>
|
||||
}),
|
||||
)
|
||||
|
||||
function decodePreferences(text: string): Preferences {
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return {}
|
||||
const normalized = ConfigNormalize.normalize(input)
|
||||
if (normalized.type === "rejected") return {}
|
||||
const info = Option.getOrUndefined(Schema.decodeUnknownOption(Info)(normalized.encoded))
|
||||
return {
|
||||
...(info?.shell === undefined ? {} : { shell: info.shell }),
|
||||
...(info?.websearch === undefined ? {} : { websearch: info.websearch }),
|
||||
}
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -108,10 +89,8 @@ export const layer = (options?: Options) =>
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const globalService = yield* Global.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const updateLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
@@ -338,50 +317,11 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
yield* reloadLock.withPermit(reconcile(initial))
|
||||
|
||||
const globalConfigPath = Effect.fn("Config.globalConfigPath")(function* () {
|
||||
const directory = initial.global ?? AbsolutePath.make(globalService.config)
|
||||
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
|
||||
const existing = yield* Effect.filter(candidates, fs.isFile)
|
||||
return existing.at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
})
|
||||
|
||||
const preferences = Effect.fn("Config.preferences")(function* () {
|
||||
const filepath = yield* globalConfigPath()
|
||||
const text = yield* fs.readFileStringSafe(filepath)
|
||||
return text === undefined ? {} : decodePreferences(text)
|
||||
})
|
||||
|
||||
const updatePreferences = Effect.fn("Config.updatePreferences")(
|
||||
function* (patch: PreferencesPatch) {
|
||||
const filepath = yield* globalConfigPath()
|
||||
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
|
||||
const updated = yield* Effect.try({
|
||||
try: () =>
|
||||
(["shell", "websearch"] as const).reduce((content, key) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(patch, key)) return content
|
||||
return applyEdits(
|
||||
content,
|
||||
modify(content, [key], patch[key] === null ? undefined : patch[key], {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
}),
|
||||
)
|
||||
}, text),
|
||||
catch: (cause) => new FSUtil.FileSystemError({ method: "config.updatePreferences", cause }),
|
||||
})
|
||||
yield* fs.writeWithDirs(filepath, updated.endsWith("\n") ? updated : `${updated}\n`)
|
||||
yield* requestReload
|
||||
return decodePreferences(updated)
|
||||
},
|
||||
(effect) => updateLock.withPermit(effect),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
return configs
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
preferences,
|
||||
updatePreferences,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
+75
-64
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -308,7 +309,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -317,7 +318,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -331,7 +332,8 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -385,9 +387,7 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,13 +464,7 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -499,19 +493,23 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -519,49 +517,57 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -54,8 +54,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -107,6 +110,7 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -133,6 +137,13 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -221,6 +232,7 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -352,6 +364,17 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
messageID: input.messageID,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -440,6 +463,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["messageID", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly messageID?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -123,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -47,7 +47,6 @@ export type ResolveInput = {
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
|
||||
readonly list?: () => Effect.Effect<Item[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ShellSelect") {}
|
||||
@@ -215,7 +214,6 @@ const layer = (options?: Options) =>
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
resolve: (input) => Effect.sync(() => resolve(input, state.get().shell, options, global.bin)),
|
||||
list: () => Effect.promise(() => list(options, global.bin)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -131,38 +131,55 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
return {
|
||||
source: repo.source,
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: input.paths,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -196,6 +197,42 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ messageID: steer })).toEqual(busy)
|
||||
expect(yield* diff({ messageID: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ messageID: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "messageID",
|
||||
})
|
||||
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3242,6 +3242,152 @@
|
||||
"summary": "Get session context"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/diff": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "messageID",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Unchanged lines around each hunk. Omit for full-file patches."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileDiff.Info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "MessageNotFoundError | SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "UnknownError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnknownErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
"summary": "Diff session turns"
|
||||
}
|
||||
},
|
||||
"/api/session/{sessionID}/inbox": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
@@ -18394,6 +18540,38 @@
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Idle": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg_"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["idle"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["succeeded", "failed", "interrupted"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "outcome"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.Info": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -18425,6 +18603,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Compaction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.Idle"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "@opencode/schema/config"
|
||||
import { ConfigShell } from "@opencode/schema/config/shell"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
@@ -20,38 +19,4 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("config.preferences", "/api/config/preferences", {
|
||||
success: Config.Preferences,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.preferences",
|
||||
summary: "Get global preferences",
|
||||
description: "Return preferences from the highest-precedence global configuration document.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("config.updatePreferences", "/api/config/preferences", {
|
||||
payload: Config.PreferencesPatch,
|
||||
success: Config.Preferences,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.updatePreferences",
|
||||
summary: "Update global preferences",
|
||||
description: "Patch preferences in the highest-precedence global configuration document.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("config.shells", "/api/config/shell", {
|
||||
success: Schema.Array(ConfigShell.Option),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.shells",
|
||||
summary: "List available shells",
|
||||
description: "Return shells available to terminal and agent execution.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "config", description: "Location-scoped configuration routes." }))
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Model } from "@opencode/schema/model"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
@@ -521,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: Schema.Struct({
|
||||
messageID: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
|
||||
}),
|
||||
to: Schema.optional(SessionMessage.ID).annotate({
|
||||
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
|
||||
}),
|
||||
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
|
||||
description: "Unchanged lines around each hunk. Omit for full-file patches.",
|
||||
}),
|
||||
}),
|
||||
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
|
||||
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.diff",
|
||||
summary: "Diff session turns",
|
||||
description:
|
||||
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -109,18 +109,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
experimental: ConfigExperimental.Info.pipe(optional),
|
||||
}) {}
|
||||
|
||||
export const Preferences = Schema.Struct({
|
||||
shell: Schema.String.pipe(optional),
|
||||
websearch: ConfigWebSearch.Selection.pipe(optional),
|
||||
}).annotate({ identifier: "Config.Preferences" })
|
||||
export interface Preferences extends Schema.Schema.Type<typeof Preferences> {}
|
||||
|
||||
export const PreferencesPatch = Schema.Struct({
|
||||
shell: Schema.NullOr(Schema.String).pipe(optional),
|
||||
websearch: Schema.NullOr(ConfigWebSearch.Selection).pipe(optional),
|
||||
}).annotate({ identifier: "Config.PreferencesPatch" })
|
||||
export interface PreferencesPatch extends Schema.Schema.Type<typeof PreferencesPatch> {}
|
||||
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: AbsolutePath.pipe(optional),
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export * as ConfigShell from "./shell.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
path: Schema.String,
|
||||
name: Schema.String,
|
||||
acceptable: Schema.Boolean,
|
||||
}).annotate({ identifier: "ConfigShell.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
@@ -280,6 +280,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
|
||||
)
|
||||
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
|
||||
|
||||
/**
|
||||
* Marks the Session going idle: every step since the previous marker belongs to
|
||||
* one turn, including prompts steered in while it was busy. A shutdown does not
|
||||
* record one, since the resumed execution continues the same turn.
|
||||
*/
|
||||
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
|
||||
export const Idle = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("idle"),
|
||||
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
|
||||
}).annotate({ identifier: "Session.Message.Idle" })
|
||||
|
||||
export const Info = Schema.Union([
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
@@ -291,6 +303,7 @@ export const Info = Schema.Union([
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
Idle,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
@@ -303,4 +316,5 @@ export type Info =
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -1,34 +1,7 @@
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ShellSelect } from "@opencode/core/shell/select"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const ConfigHandler = HttpApiBuilder.group(Api, "server.config", (handlers) =>
|
||||
handlers
|
||||
.handle("config.get", () => Config.Service.use((config) => config.entries()))
|
||||
.handle(
|
||||
"config.preferences",
|
||||
Effect.fn(function* () {
|
||||
const config = yield* Config.Service
|
||||
if (!config.preferences) return yield* Effect.die(new Error("Config preferences are unavailable"))
|
||||
return yield* config.preferences().pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"config.updatePreferences",
|
||||
Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
if (!config.updatePreferences) return yield* Effect.die(new Error("Config preference updates are unavailable"))
|
||||
return yield* config.updatePreferences(ctx.payload).pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"config.shells",
|
||||
Effect.fn(function* () {
|
||||
const shell = yield* ShellSelect.Service
|
||||
if (!shell.list) return yield* Effect.die(new Error("Shell discovery is unavailable"))
|
||||
return yield* shell.list()
|
||||
}),
|
||||
),
|
||||
handlers.handle("config.get", () => Config.Service.use((config) => config.entries())),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import type { Snapshot } from "@opencode/core/snapshot"
|
||||
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function missingSession(error: Session.NotFoundError) {
|
||||
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
|
||||
})
|
||||
}
|
||||
|
||||
export function missingMessage(error: Session.MessageNotFoundError) {
|
||||
return new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
|
||||
export function failedSnapshot(operation: string, sessionID: Session.ID) {
|
||||
return (error: Snapshot.Error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
|
||||
Effect.annotateLogs({ ref, sessionID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
SkillNotFoundError,
|
||||
UnknownError,
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, missingSession } from "./session-error"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -212,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
return {
|
||||
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.ForkEmptyError",
|
||||
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
|
||||
@@ -448,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
files: ctx.payload.files,
|
||||
})
|
||||
return {
|
||||
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.MessageNotFoundError",
|
||||
(error) =>
|
||||
new MessageNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
messageID: error.messageID,
|
||||
message: `Message not found: ${error.messageID}`,
|
||||
}),
|
||||
data: yield* session.revert
|
||||
.stage({ ...ctx.params, ...ctx.payload })
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
|
||||
),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -481,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
"session.revert.clear",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
||||
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* session.revert
|
||||
.clear(ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.BusyError", busySession),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
@@ -527,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.diff",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
|
||||
Effect.catchTag(
|
||||
"Session.TurnRangeError",
|
||||
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
|
||||
),
|
||||
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.inbox.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -59,54 +59,6 @@ it.live("returns ordered config entries for the requested directory", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("updates global preferences without replacing unrelated JSONC", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-config-preferences-")))
|
||||
const global = path.join(tmp.path, "global")
|
||||
const config = path.join(global, "opencode.jsonc")
|
||||
yield* Effect.promise(() => fs.mkdir(global, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
config,
|
||||
`{
|
||||
// keep this comment
|
||||
"model": "provider/model",
|
||||
"shell": "bash"
|
||||
}
|
||||
`,
|
||||
),
|
||||
)
|
||||
const server = yield* startServer(global)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/config/preferences", server.base), {
|
||||
method: "PATCH",
|
||||
headers: { ...server.headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ shell: null, websearch: { provider: "random" } }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({ websearch: { provider: "random" } })
|
||||
const text = yield* Effect.promise(() => fs.readFile(config, "utf8"))
|
||||
expect(text).toContain("// keep this comment")
|
||||
expect(text).toContain('"model": "provider/model"')
|
||||
expect(text).not.toContain('"shell"')
|
||||
expect(text).toContain('"websearch"')
|
||||
|
||||
const preferences = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/config/preferences", server.base), { headers: server.headers }),
|
||||
)
|
||||
expect(preferences.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => preferences.json())).toEqual({ websearch: { provider: "random" } })
|
||||
|
||||
const shells = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/config/shell", server.base), { headers: server.headers }),
|
||||
)
|
||||
expect(shells.status).toBe(200)
|
||||
expect(Array.isArray(yield* Effect.promise(() => shells.json()))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, setDefaultTimeout } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
it.live("serves turn diffs by user message with range validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
|
||||
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
|
||||
// Deliver the prompt and one step the way the runner would, without a model.
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: (sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: ids.assistant,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
}),
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const request = (path: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
|
||||
})
|
||||
const created = yield* request("/api/session", { location: { directory: tmp.path } })
|
||||
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
|
||||
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
|
||||
|
||||
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
|
||||
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
|
||||
// Not a git repository, so steps record no snapshots and the turn has no diff.
|
||||
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
|
||||
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
|
||||
status: 400,
|
||||
body: { _tag: "InvalidRequestError", field: "messageID" },
|
||||
})
|
||||
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
|
||||
status: 404,
|
||||
body: { _tag: "MessageNotFoundError" },
|
||||
})
|
||||
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
|
||||
}),
|
||||
)
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
icon="archive"
|
||||
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
|
||||
status="running"
|
||||
locked
|
||||
hideDetails
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
icon="archive"
|
||||
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
|
||||
status="running"
|
||||
locked
|
||||
hideDetails
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
|
||||
|
||||
export type ReasoningMode = "hidden" | "compact" | "full"
|
||||
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
|
||||
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
|
||||
type Content = SessionMessageAssistant["content"][number]
|
||||
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function isNotice(message: SessionMessageInfo): message is Notice {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
|
||||
return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim() || timelineNoticeRequired(message)
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "idle") return rows
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
|
||||
@@ -93,33 +93,23 @@ export const additionalIcons = {
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
sliders: `<path d="M11.7778 4.66797H14.4444M11.7778 4.66797C11.7778 5.77254 10.8823 6.66797 9.77776 6.66797C8.67319 6.66797 7.77776 5.77254 7.77776 4.66797M11.7778 4.66797C11.7778 3.5634 10.8823 2.66797 9.77776 2.66797C8.67319 2.66797 7.77776 3.5634 7.77776 4.66797M1.55554 4.66797H7.77776M4.22221 11.3346H1.55554M4.22221 11.3346C4.22221 12.4392 5.11764 13.3346 6.22221 13.3346C7.32678 13.3346 8.22221 12.4392 8.22221 11.3346M4.22221 11.3346C4.22221 10.2301 5.11764 9.33464 6.22221 9.33464C7.32678 9.33464 8.22221 10.2301 8.22221 11.3346M14.4444 11.3346H8.22221" stroke="currentColor"/>`,
|
||||
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"arrow-down-to-line": `<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
|
||||
warning: `<path d="M10 7.91667V11.6667M10 13.7417V13.75M10 2.5L1.875 16.25H18.125L10 2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
reset: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
|
||||
providers: `<path d="M5.11115 3.33252H10.8889M5.11115 3.33252L3.33337 3.33254V12.6658H5.11115M5.11115 3.33252L5.11108 1.55469M10.8889 3.33252L12.6667 3.33252V12.6658H10.8889M10.8889 3.33252L10.8889 1.55469M10.8889 12.6658H5.11115M10.8889 12.6658L10.8889 14.4435M5.11115 12.6658L5.11108 14.4435M8 1.55469V3.33247M14.4444 7.99915H12.6666M14.4444 5.11023H12.6666M14.4444 10.8881H12.6666M8 14.4435V12.6658M1.55554 7.99915H3.33332M1.55554 10.8881H3.33332M1.55554 5.11023H3.33332M6 9.99915V5.99915H10V9.99915H6Z" stroke="currentColor"/>`,
|
||||
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M14 8C9.83336 8 8 9.83336 8 14C8 9.83336 6.16666 8 2 8C6.16666 8 8 6.16666 8 2C8 6.16666 9.83336 8 14 8Z" stroke="currentColor"/>`,
|
||||
appearance: `<path d="M2.00012 13.9997L13.9998 2" stroke="currentColor" stroke-linecap="square"/><path d="M2.00012 8.35278L8.3529 2" stroke="currentColor" stroke-linecap="square"/><path d="M2.00012 2.70586L2.70599 2" stroke="currentColor" stroke-linecap="square"/><path d="M7.64722 14.0012L14 7.64844" stroke="currentColor" stroke-linecap="square"/><path d="M13.2935 13.9988L13.9993 13.293" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
notifications: `<path d="M14.8889 6.77778V9.11112C14.8889 10.093 14.8889 10.8889 14.8889 10.8889H11.3334M5.55558 10.8889H2C2 10.8889 2.00001 10.093 2.00001 9.11112V3.77778C2.00001 2.79594 2.00009 2 2.00009 2H9.44531M5.55558 10.8889V13.1111H8.44447H11.3334V10.8889M5.55558 10.8889H11.3334" stroke="currentColor"/><path d="M13 5C14.1046 5 15 4.10457 15 3C15 1.89543 14.1046 1 13 1C11.8954 1 11 1.89543 11 3C11 4.10457 11.8954 5 13 5Z" stroke="currentColor"/>`,
|
||||
extensions: `<path d="M8 6.75047V9.25047M10.668 6.75047V9.25047M5.33398 6.75047V9.25047M1.33398 2.44531V13.5553H14.6673V2.44531H1.33398Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
|
||||
appearance: `<path d="M2.707 14.707L14.707 2.707M2.707 9.06L9.06 2.707M2.707 3.413L3.413 2.707M8.354 14.707L14.707 8.354M14.000 14.706L14.706 14.000" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
notifications: `<path d="M15.389 7.278V9.611C15.389 10.593 15.389 11.389 15.389 11.389H11.833M6.056 11.389H2.5C2.5 11.389 2.5 10.593 2.5 9.611V4.278C2.5 3.296 2.5 2.5 2.5 2.5H9.945M6.056 11.389V13.611H8.944H11.833V11.389M6.056 11.389H11.833" stroke="currentColor"/><circle cx="14.5" cy="4.5" r="2" stroke="currentColor" fill="currentColor"/>`,
|
||||
extensions: `<path d="M9.166 6.805V9.305M11.834 6.805V9.305M6.5 6.805V9.305M2.5 2.5V13.61H15.833V2.5H2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
cube: `<path d="M10 2.5L16.5 6.25V13.75L10 17.5L3.5 13.75V6.25L10 2.5Z" stroke="currentColor"/><path d="M10 10L16.5 6.25M10 10V17.5M10 10L3.5 6.25" stroke="currentColor"/>`,
|
||||
"post-skill": `<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" stroke="currentColor"/><path d="M5.5 7.5H10.5M5.5 10.5H14.5" stroke="currentColor"/>`,
|
||||
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
|
||||
}
|
||||
|
||||
export function additionalIconViewBox(name: keyof typeof additionalIcons) {
|
||||
return name === "magnifying-glass" ||
|
||||
name === "arrow-undo-down" ||
|
||||
name === "subagent" ||
|
||||
name === "notifications" ||
|
||||
name === "appearance" ||
|
||||
name === "extensions" ||
|
||||
name === "sliders" ||
|
||||
name === "providers" ||
|
||||
name === "models"
|
||||
? "0 0 16 16"
|
||||
: "0 0 20 20"
|
||||
return name === "magnifying-glass" || name === "arrow-undo-down" || name === "subagent" ? "0 0 16 16" : "0 0 20 20"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user