Skip to content

Commit 877b04f

Browse files
usrnk1Hona
andauthored
feat(desktop): redesign settings navigation and project editing (#47983)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
1 parent 41e5d1b commit 877b04f

70 files changed

Lines changed: 3460 additions & 1473 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { expect, test } from "@playwright/test"
2+
import type { ProjectUpdateInput } from "@opencode/client/promise"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
5+
const directory = "/repo/project-autosave"
6+
const project = {
7+
id: "proj_autosave",
8+
canonical: directory,
9+
name: "Autosave project",
10+
icon: { color: "orange" },
11+
commands: { start: "echo setup" },
12+
sandboxes: [],
13+
time: { created: 1, updated: 1 },
14+
}
15+
16+
test.use({ viewport: { width: 1280, height: 900 } })
17+
18+
for (const field of [
19+
{
20+
name: "color",
21+
label: "",
22+
initial: "orange",
23+
next: "blue",
24+
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
25+
},
26+
{
27+
name: "name",
28+
label: "Project name",
29+
initial: project.name,
30+
next: "Renamed project",
31+
patches: [{ name: "Renamed project" }, { name: project.name }],
32+
},
33+
{
34+
name: "startup script",
35+
label: "Worktree startup script",
36+
initial: project.commands.start,
37+
next: "bun install",
38+
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
39+
},
40+
]) {
41+
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
42+
await mockOpenCodeServer(page, {
43+
directory,
44+
project,
45+
provider: { all: [], connected: [], default: {} },
46+
sessions: [],
47+
pageMessages: () => ({ items: [] }),
48+
})
49+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
50+
await page.addInitScript(
51+
({ directory, server }) => {
52+
localStorage.setItem(
53+
"opencode.global.dat:server",
54+
JSON.stringify({
55+
list: [{ type: "http", http: { url: server } }],
56+
projects: {
57+
local: [{ worktree: directory, expanded: true }],
58+
[server]: [{ worktree: directory, expanded: true }],
59+
},
60+
}),
61+
)
62+
},
63+
{ directory, server },
64+
)
65+
const pending = Promise.withResolvers<void>()
66+
const patches: unknown[] = []
67+
await page.route(`**/api/project/${project.id}`, async (route) => {
68+
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
69+
patches.push(patch)
70+
if (patches.length === 1) await pending.promise
71+
await route.fulfill({ json: { ...project, ...patch } })
72+
})
73+
await page.goto("/settings")
74+
const settings = page.getByTestId("settings-screen")
75+
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
76+
await settings.getByRole("button", { name: project.name, exact: true }).click()
77+
const edit = async (value: string) => {
78+
if (field.name === "color") {
79+
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
80+
return
81+
}
82+
const input = settings.getByRole("textbox", { name: field.label, exact: true })
83+
await input.fill(value)
84+
await input.blur()
85+
}
86+
87+
const first = page.waitForRequest(
88+
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
89+
)
90+
await edit(field.next)
91+
await first
92+
await edit(field.initial)
93+
expect(patches).toEqual([field.patches[0]])
94+
pending.resolve()
95+
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
96+
expect(patches).toEqual(field.patches)
97+
98+
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
99+
await settings.getByRole("tab", { name: project.name, exact: true }).click()
100+
if (field.name === "color") {
101+
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
102+
"aria-pressed",
103+
"true",
104+
)
105+
return
106+
}
107+
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
108+
})
109+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { expect, test } from "@playwright/test"
2+
import type { ConfigEntry } from "@opencode/client/promise"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
5+
const directory = "/repo/configured-lsp"
6+
const entries: ConfigEntry[] = [
7+
{
8+
type: "document",
9+
path: "/config/opencode.json",
10+
info: {
11+
lsp: {
12+
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
13+
},
14+
},
15+
},
16+
{
17+
type: "document",
18+
path: `${directory}/opencode.jsonc`,
19+
info: {
20+
lsp: {
21+
typescript: { disabled: true },
22+
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
23+
},
24+
},
25+
},
26+
]
27+
28+
test.use({ viewport: { width: 1280, height: 900 } })
29+
30+
test.beforeEach(async ({ page }) => {
31+
await mockOpenCodeServer(page, {
32+
directory,
33+
project: {
34+
id: "proj_configured_lsp",
35+
canonical: directory,
36+
name: "Configured LSP project",
37+
sandboxes: [],
38+
time: { created: 1, updated: 1 },
39+
},
40+
provider: { all: [], connected: [], default: {} },
41+
sessions: [],
42+
pageMessages: () => ({ items: [] }),
43+
})
44+
await page.addInitScript((directory) => {
45+
localStorage.setItem(
46+
"opencode.global.dat:server",
47+
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
48+
)
49+
}, directory)
50+
await page.goto("/")
51+
await page.getByRole("button", { name: "Settings", exact: true }).click()
52+
const settings = page.getByTestId("settings-screen")
53+
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
54+
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
55+
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
56+
})
57+
58+
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
59+
const ready = Promise.withResolvers<void>()
60+
await page.route(
61+
(url) => url.pathname === "/api/config",
62+
async (route) => {
63+
await ready.promise
64+
await route.fulfill({ json: entries })
65+
},
66+
)
67+
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
68+
const settings = page.getByTestId("settings-screen")
69+
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
70+
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
71+
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
72+
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
73+
ready.resolve()
74+
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
75+
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
76+
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
77+
await expect(typescript).toContainText("Disabled in config")
78+
await expect(typescript).toContainText(".ts, .tsx")
79+
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
80+
"Enabled in config",
81+
)
82+
await expect(panel.getByRole("switch")).toHaveCount(0)
83+
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
84+
await page.setViewportSize({ width: 390, height: 844 })
85+
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
86+
await expect
87+
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
88+
.toBeLessThanOrEqual(1)
89+
})
90+
91+
for (const lsp of [true, false]) {
92+
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
93+
await page.route(
94+
(url) => url.pathname === "/api/config",
95+
(route) =>
96+
route.fulfill({
97+
json: [{ type: "document", info: { lsp } }],
98+
}),
99+
)
100+
const settings = page.getByTestId("settings-screen")
101+
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
102+
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
103+
await expect(
104+
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
105+
).toBeVisible()
106+
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
107+
})
108+
}
109+
110+
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
111+
const state = { fail: true }
112+
await page.route(
113+
(url) => url.pathname === "/api/config",
114+
(route) =>
115+
route.fulfill({
116+
status: state.fail ? 404 : 200,
117+
json: state.fail ? {} : entries,
118+
}),
119+
)
120+
const settings = page.getByTestId("settings-screen")
121+
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
122+
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
123+
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
124+
state.fail = false
125+
await panel.getByRole("button", { name: "Retry", exact: true }).click()
126+
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
127+
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
128+
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
129+
})

‎packages/app/e2e/regression/remote-session-settings.spec.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => {
2020
// one toggle sweeps every connected server, not just the focused one.
2121
await mockServers(page, permissionRequests, permissionResponses, {
2222
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
23+
preferencesUnavailable: true,
2324
})
2425
await configureServers(page)
2526

@@ -36,6 +37,8 @@ test("session settings use the remote server context", async ({ page }) => {
3637
await expect(page.getByRole("dialog")).toHaveCount(0)
3738
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
3839
await expect(sessionHeading).toBeHidden()
40+
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
41+
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
3942
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
4043
const input = autoAccept.getByRole("switch")
4144
await expect(autoAccept).toBeVisible()
@@ -63,17 +66,29 @@ test("session settings use the remote server context", async ({ page }) => {
6366
},
6467
])
6568

69+
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
70+
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
71+
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
72+
await expect(settings.getByRole("tab")).toHaveText([
73+
"127.0.0.1:4097",
74+
"Projects",
75+
"Worktrees",
76+
"Providers",
77+
"Models",
78+
"Extensions",
79+
])
6680
await settings.getByRole("tab", { name: "Models" }).click()
6781
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
6882
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
83+
await settings.getByRole("button", { name: "Back to settings" }).click()
6984
await settings.getByRole("button", { name: "Back to app" }).click()
7085
await expect(settings).toBeHidden()
7186
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
7287
await expect(sessionHeading).toBeVisible()
7388
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
7489
await page.keyboard.press("Control+]")
7590
await expect(page).toHaveURL("/settings")
76-
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
91+
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
7792
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
7893
await page.keyboard.press("Escape")
7994
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
@@ -311,6 +326,7 @@ type MockServerOptions = {
311326
listFailures?: Record<string, number>
312327
// Records /api/session/:id GETs so tests can assert session resyncs.
313328
sessionGets?: string[]
329+
preferencesUnavailable?: boolean
314330
}
315331

316332
async function mockServers(
@@ -402,6 +418,11 @@ async function mockServers(
402418
directory,
403419
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
404420
})
421+
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
422+
if (url.pathname === "/api/config/shell")
423+
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
424+
if (url.pathname === "/api/websearch/provider")
425+
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
405426
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
406427
if (url.pathname === "/api/vcs")
407428
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })

‎packages/app/e2e/regression/server-dialog-focus.spec.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
2727
const settings = page.getByTestId("settings-screen")
2828
await expect(settings).toBeVisible()
2929
await expect(page.getByRole("dialog")).toHaveCount(0)
30-
await settings.getByRole("tab", { name: "Servers" }).click()
31-
await settings.getByRole("button", { name: "Add server" }).click()
30+
const add = settings.getByRole("button", { name: "Add server" })
31+
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
32+
await expect(add).toHaveCSS("opacity", "0")
33+
await group.hover()
34+
await expect(add).toHaveCSS("opacity", "1")
35+
await add.click()
3236

3337
const editor = page.getByRole("dialog", { name: "Add server" })
3438
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()

‎packages/app/e2e/regression/session-project-menu.spec.ts‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) {
198198
await expect(settings).toBeFocused()
199199
await expect(page.getByRole("tooltip")).toBeHidden()
200200
await page.keyboard.press("Enter")
201-
const dialog = page.getByRole("dialog")
202-
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
203-
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
204-
project.name,
205-
)
201+
const settingsScreen = page.getByTestId("settings-screen")
202+
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
203+
await expect(
204+
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
205+
).toHaveValue(project.name)
206206
await expect(menu).toBeHidden()
207-
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
208-
await expect(dialog).toBeHidden()
207+
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
208+
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
209+
await expect(settingsScreen).toBeHidden()
209210
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
210211

211212
await page.setViewportSize({ width: 1440, height: 900 })
@@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) {
272273
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
273274
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
274275
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
275-
const dialog = page.getByRole("dialog")
276-
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
277-
fixture.project.name,
278-
)
279-
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
280-
await expect(dialog).toBeHidden()
276+
const settingsScreen = page.getByTestId("settings-screen")
277+
await expect(
278+
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
279+
).toHaveValue(fixture.project.name)
280+
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
281+
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
282+
await expect(settingsScreen).toBeHidden()
281283
}
282284
await trigger.click()
283285
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()

0 commit comments

Comments
 (0)