diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts new file mode 100644 index 000000000000..93e134a860b3 --- /dev/null +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -0,0 +1,19 @@ +import { expect, story } from "../../storybook/playwright/story" + +story("shows question navigation shortcuts", async ({ mount, page }) => { + const component = await mount("app-current-session-surface--question-request") + const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎")) + const next = component.getByRole("button", { name: "Next", exact: true }) + await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut) + await next.click() + await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut) + const back = component.getByRole("button", { name: "Back", exact: true }) + const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←")) + await expect(back).toHaveText("Back") + await back.hover() + await expect(page.getByRole("tooltip")).toContainText(backShortcut) + await page.keyboard.press( + await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")), + ) + await expect(next).toBeVisible() +}) diff --git a/packages/app/component-tests/timeline-detail.spec.ts b/packages/app/component-tests/timeline-detail.spec.ts index 09fafdf1dc93..a391bf489a50 100644 --- a/packages/app/component-tests/timeline-detail.spec.ts +++ b/packages/app/component-tests/timeline-detail.spec.ts @@ -158,6 +158,10 @@ for (const direction of ["ltr", "rtl"]) { story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => { await page.setViewportSize({ width: 900, height: 900 }) const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } }) + await expect(component.locator('[data-slot="timeline-detail-scale"]')).toHaveCSS("margin-top", "16px") + const advanced = component.locator('[data-slot="timeline-detail-advanced"]') + await expect(advanced).toHaveCSS("margin-top", "0px") + await expect(advanced).toHaveCSS("padding-top", "8px") await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS( "color", await component @@ -177,28 +181,46 @@ for (const direction of ["ltr", "rtl"]) { ), ) if (theme === "light") { - await expect(track.locator("span").first()).toHaveCSS("background-image", "none") + await expect(track.locator("span").last()).toHaveCSS("background-image", "none") await expect(track).toHaveCSS( "--timeline-detail-marker-background", await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()), ) } if (theme === "dark") { - await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none") + await expect(track.locator("span").last()).not.toHaveCSS("background-image", "none") } await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) }) const list = component.locator('[data-slot="timeline-detail-list"]') + const columns = component.locator('[data-slot="timeline-detail-columns"]') await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px") + const advancedTrigger = component.getByRole("button", { name: "Advanced", exact: true }) + expect( + await advancedTrigger.evaluate((element) => { + const trigger = element.getBoundingClientRect() + const heading = document + .querySelector('[data-slot="timeline-detail-columns"] > :nth-child(2)')! + .getBoundingClientRect() + return Math.abs(trigger.y + trigger.height / 2 - (heading.y + heading.height / 2)) + }), + ).toBeLessThan(1) + expect( + await list.evaluate((element) => { + const list = element.getBoundingClientRect() + const trigger = document + .querySelector('[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"]')! + .getBoundingClientRect() + return list.y - trigger.bottom + }), + ).toBe(8) for (const [column, field] of [ [2, "placement"], [3, "details"], ] as const) { - const heading = await component - .locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`) - .evaluate((element) => { - const rect = element.getBoundingClientRect() - return rect.x + rect.width / 2 - }) + const heading = await columns.locator(`> :nth-child(${column})`).evaluate((element) => { + const rect = element.getBoundingClientRect() + return rect.x + rect.width / 2 + }) const toggle = await component .locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`) .evaluate((element) => { @@ -207,7 +229,7 @@ for (const direction of ["ltr", "rtl"]) { }) expect(Math.abs(heading - toggle)).toBeLessThan(1) } - await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px") + await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "8px") for (const width of [900, 320]) { await page.setViewportSize({ width, height: 900 }) await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible() @@ -227,6 +249,7 @@ for (const direction of ["ltr", "rtl"]) { const slider = component.getByRole("slider", { name: "Timeline detail" }) const track = component.locator('[data-slot="timeline-detail-track"]') + await expect(slider).not.toHaveCSS("cursor", "pointer") expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe( await component .locator('[data-slot="timeline-detail-scale"]') @@ -237,6 +260,11 @@ for (const direction of ["ltr", "rtl"]) { for (const position of [0, 1, 2, 3, 4]) { if (position > 0) await slider.press("ArrowUp") await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`) + await expect(track.locator("span[data-selected]")).toHaveCount(position + 1) + await expect(track.locator("span").nth(position)).toHaveCSS( + "background-color", + await track.evaluate((element) => getComputedStyle(element, "::before").backgroundColor), + ) const fill = await track.evaluate((element) => { const style = getComputedStyle(element, "::before") return { diff --git a/packages/app/e2e/regression/project-autosave.spec.ts b/packages/app/e2e/regression/project-autosave.spec.ts index 846bad64b526..aa78331109ba 100644 --- a/packages/app/e2e/regression/project-autosave.spec.ts +++ b/packages/app/e2e/regression/project-autosave.spec.ts @@ -96,7 +96,7 @@ for (const field of [ expect(patches).toEqual(field.patches) await settings.getByRole("tab", { name: "Worktrees", exact: true }).click() - await settings.getByRole("tab", { name: project.name, exact: true }).click() + await settings.getByRole("tab", { name: "General", exact: true }).click() if (field.name === "color") { await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute( "aria-pressed", diff --git a/packages/app/e2e/regression/settings-loading.spec.ts b/packages/app/e2e/regression/settings-loading.spec.ts index fb9ef47ca390..a34d03a040e6 100644 --- a/packages/app/e2e/regression/settings-loading.spec.ts +++ b/packages/app/e2e/regression/settings-loading.spec.ts @@ -134,10 +134,52 @@ test("project settings open as a nested autosaving view", async ({ page }) => { 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: "General", exact: true })).toBeVisible() await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible() await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible() await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0) + await expect(settings.getByTitle(directory)).toHaveCount(0) + const projectIcon = settings.locator('.settings-tab-header [data-component="project-avatar-v2"]') + await expect(projectIcon).toHaveCSS("width", "32px") + const projectOptions = settings + .locator(".settings-tab-header") + .getByRole("button", { name: "More options", exact: true }) + await expect(projectOptions).toHaveCSS("width", "28px") + await expect + .poll(async () => + new Set( + await Promise.all( + [ + settings.getByRole("button", { name: "Back to projects", exact: true }), + settings.getByRole("heading", { name: "Settings demo", exact: true }), + projectIcon, + projectOptions, + ].map((item) => + item.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return Math.round(bounds.top + bounds.height / 2) + }), + ), + ), + ).size, + ) + .toBe(1) + await expect + .poll(async () => { + const back = await settings + .getByRole("button", { name: "Back to projects", exact: true }) + .evaluate((element) => element.getBoundingClientRect().top) + const general = await settings + .getByRole("tab", { name: "General", exact: true }) + .evaluate((element) => element.getBoundingClientRect().top) + return Math.round(general - back) + }) + .toBe(72) + await projectOptions.click() + const projectMenu = page.getByRole("menu") + await expect(projectMenu.getByRole("menuitem")).toHaveText(["Clear notifications", "Close"]) + await page.keyboard.press("Escape") + await expect(projectMenu).toBeHidden() const name = settings.getByRole("textbox", { name: "Project name", exact: true }) const saved = page.waitForRequest( @@ -146,7 +188,8 @@ test("project settings open as a nested autosaving view", async ({ page }) => { 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() + await expect(settings.getByRole("tab", { name: "General", exact: true })).toBeVisible() + await expect(settings.getByRole("heading", { name: "Renamed project", exact: true })).toBeVisible() const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true }) const scriptSaved = page.waitForRequest( diff --git a/packages/app/e2e/regression/settings-project-edges.spec.ts b/packages/app/e2e/regression/settings-project-edges.spec.ts index 73daf5881bf1..dceba4b09f31 100644 --- a/packages/app/e2e/regression/settings-project-edges.spec.ts +++ b/packages/app/e2e/regression/settings-project-edges.spec.ts @@ -5,7 +5,7 @@ for (const colorScheme of ["light", "dark"] as const) { test.describe(colorScheme, () => { test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } }) - test("project card edges stay inside the settings scrollport", async ({ page }, info) => { + test("project list actions and edges stay inside the settings scrollport", async ({ page }, info) => { const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({ id: `project-${index}`, name, @@ -20,10 +20,18 @@ for (const colorScheme of ["light", "dark"] as const) { sessions: [], pageMessages: () => ({ items: [] }), provider: { all: [], connected: [], default: {} }, + fileList: () => [], }) await page.route("**/api/project", (route) => route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }), ) + await page.route("**/api/project/project-0", async (route) => { + const update = route.request().postDataJSON() + await route.fulfill({ + json: { ...projects[0], ...update }, + headers: { "access-control-allow-origin": "*" }, + }) + }) await page.addInitScript((projects) => { localStorage.setItem( "opencode.global.dat:server", @@ -40,18 +48,76 @@ for (const colorScheme of ["light", "dark"] as const) { const panel = settings.getByRole("tabpanel") await expect(panel.getByText("rebase", { exact: true })).toBeVisible() await expect(panel.getByText("Playground", { exact: true })).toBeVisible() + await expect(panel.getByRole("button", { name: "Add project", exact: true })).toBeVisible() + const list = panel.getByRole("list") + await expect(list.getByRole("listitem")).toHaveCount(4) + const projectButton = panel.getByRole("button", { name: "rebase", exact: true }) + const projectCard = list.getByRole("listitem").filter({ hasText: "rebase" }) + const projectName = projectButton.getByText("rebase", { exact: true }) + await expect(projectName).toHaveCSS("font-weight", "530") + await expect(projectName).toHaveCSS("line-height", "20px") + await expect(projectButton.getByText("/projects/rebase", { exact: true })).toHaveCount(0) + await expect + .poll(() => + list.evaluate((group) => { + const icons = Array.from(group.querySelectorAll('[data-component="project-avatar-v2"]')) + const first = icons.at(0)?.getBoundingClientRect() + const last = icons.at(-1)?.getBoundingClientRect() + const bounds = group.getBoundingClientRect() + if (!first || !last) return [] + return [first.top - bounds.top, bounds.bottom - last.bottom, first.left - bounds.left] + }), + ) + .toEqual([16, 16, 16]) + await expect + .poll(() => + list + .locator(".settings-project-row-shell") + .filter({ hasText: "rebase" }) + .evaluate((row) => { + const group = row.parentElement + if (!group) return 0 + return ( + row.getBoundingClientRect().left - + group.getBoundingClientRect().left + + Number.parseFloat(getComputedStyle(row, "::after").insetInlineStart) + ) + }), + ) + .toBe(16) + const chevron = projectButton.locator('svg:has(use[href="#opencode-v2-icon-chevron-right"])') + await expect(chevron).toHaveCount(0) + const hoverColor = await projectCard.evaluate((element) => { + const probe = document.createElement("div") + probe.style.backgroundColor = "var(--v2-background-bg-layer-02)" + element.append(probe) + const color = getComputedStyle(probe).backgroundColor + probe.remove() + return color + }) + await projectCard.hover() + await expect(projectCard).toHaveCSS("background-color", hoverColor) + const more = projectCard.getByRole("button", { name: "More options", exact: true }) + await more.click() + const menu = page.getByRole("menu") + await expect(menu.getByRole("menuitem")).toHaveText(["Edit", "Rename", "Clear notifications", "Close"]) + await menu.getByRole("menuitem", { name: "Close", exact: true }).hover() + await expect(projectCard).toHaveCSS("background-color", hoverColor) + await expect(menu.getByRole("separator")).toHaveCount(1) + await menu.getByRole("menuitem", { name: "Edit", exact: true }).click() + await expect(panel.getByRole("heading", { name: "rebase", exact: true })).toBeVisible() + await settings.getByRole("button", { name: "Back to projects", exact: true }).click() + await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeVisible() await page.evaluate(() => document.fonts.ready) for (const width of [1280, 1050, 960, 720, 600]) { await page.setViewportSize({ width, height: 720 }) await page.mouse.move(0, 0) await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" }) - // Raised cards paint a half-pixel border outside their box. The scrollport - // must leave room for that border and the soft shadow on both sides. + // Each project card stays fully inside every horizontal clip ancestor. await expect .poll(() => - panel.getByText("rebase", { exact: true }).evaluate((label) => { - const row = label.parentElement!.parentElement! + projectCard.evaluate((row) => { const bounds = row.getBoundingClientRect() const clips = [] for (let parent = row.parentElement; parent; parent = parent.parentElement) { @@ -73,11 +139,45 @@ for (const colorScheme of ["light", "dark"] as const) { await settings.getByRole("button", { name: "Back to projects", exact: true }).click() await expect(panel.getByText("rebase", { exact: true })).toBeVisible() + const secondProject = panel.getByRole("button", { name: "dinocms", exact: true }) + await list + .getByRole("listitem") + .filter({ hasText: "dinocms" }) + .getByRole("button", { name: "More options", exact: true }) + .click() + await page.getByRole("menuitem", { name: "Close", exact: true }).click() + await expect(secondProject).toHaveCount(0) + await page.setViewportSize({ width: 1280, height: 260 }) await panel.getByText("rebase", { exact: true }).hover() await page.mouse.wheel(0, 400) await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 }) await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 }) + + await page.setViewportSize({ width: 1280, height: 720 }) + await more.click() + await menu.getByRole("menuitem", { name: "Rename", exact: true }).click() + const rename = panel.getByRole("textbox", { name: "Rename", exact: true }) + await expect(rename).toBeFocused() + await expect(rename).toHaveValue("rebase") + await expect(rename).toHaveCSS("border-radius", "0px") + await rename.fill("Renamed project") + const renamed = page.waitForRequest( + (request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/project-0", + ) + await rename.press("Enter") + expect((await renamed).postDataJSON()).toEqual({ name: "Renamed project" }) + await expect(panel.getByRole("button", { name: "Renamed project", exact: true })).toBeVisible() + + await panel.getByRole("button", { name: "Add project", exact: true }).click() + const picker = page.getByRole("dialog", { name: "Open project", exact: true }) + await picker.getByRole("combobox").fill("/projects/added") + await picker.getByRole("combobox").press("Enter") + const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true }) + await expect(selectFolder).toBeEnabled() + await selectFolder.click() + await expect(picker).toBeHidden() + await expect(panel.getByRole("heading", { name: "added", exact: true })).toBeVisible() }) }) } diff --git a/packages/app/e2e/regression/settings-search.spec.ts b/packages/app/e2e/regression/settings-search.spec.ts index e7851ab2379e..65818cdc3022 100644 --- a/packages/app/e2e/regression/settings-search.spec.ts +++ b/packages/app/e2e/regression/settings-search.spec.ts @@ -30,6 +30,19 @@ function projectList(count: number) { })) } +async function persistProjects(page: Page, projects: ReturnType) { + await page.evaluate((projects) => { + const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}") + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + ...value, + projects: { ...value.projects, local: projects.map((project) => ({ worktree: project.canonical })) }, + }), + ) + }, projects) +} + function ui(page: Page) { const settings = page.getByTestId("settings-screen") return { @@ -50,6 +63,14 @@ 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.addInitScript((directory) => { + const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}") + if (value.projects?.local) return + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ ...value, projects: { ...value.projects, local: [{ worktree: directory, expanded: true }] } }), + ) + }, directory) 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() @@ -172,9 +193,22 @@ test("Models and Shortcuts autofocus their filters on normal navigation", async await expect(result).toBeFocused() }) +test("Shortcuts search keeps focus while filtering", async ({ page }) => { + const view = ui(page) + await view.settings.getByRole("tab", { name: "Shortcuts", exact: true }).click() + const search = view.settings.getByRole("searchbox", { name: "Search shortcuts", exact: true }) + await search.press("p") + await expect(search).toBeFocused() + await page.keyboard.type("alette") + await expect(search).toHaveValue("palette") + await expect(view.settings.getByText("Command palette", { exact: true })).toBeVisible() +}) + 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) })) + const inventory = projectList(count) + await page.route("**/api/project", (route) => route.fulfill({ json: inventory })) + await persistProjects(page, inventory) await page.reload() const view = ui(page) await view.search.fill("OpenCode") @@ -188,45 +222,84 @@ for (const count of [7, 8]) { await expect(search).toHaveCount(0) return } - await expect(search).toBeFocused() + await expect(search).not.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 expect(view.settings.getByText('No results for "missing-project"', { 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 expect(search).not.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() - await page.route("**/api/project", async (route) => { - await inventory.promise - await route.fulfill({ json: projectList(8) }) +test("Projects shows an add action in its empty state", async ({ page }) => { + await persistProjects(page, []) + await page.reload() + const view = ui(page) + await view.settings.getByRole("tab", { name: "Projects", exact: true }).click() + await expect(view.settings.getByText("No projects yet", { exact: true })).toBeVisible() + await expect(view.settings.getByText("Create your first project to get started", { exact: true })).toBeVisible() + const emptyCard = view.settings.locator('[data-component="settings-project-empty-card"]') + await expect(emptyCard).toHaveCSS("border-radius", "8px") + await expect(emptyCard).toHaveCSS("padding-top", "96px") + await expect(emptyCard).toHaveCSS("padding-bottom", "96px") + const emptyBackground = await emptyCard.evaluate((element) => { + const probe = document.createElement("div") + probe.style.background = + "color-mix(in oklab, var(--v2-background-bg-base) 50%, var(--v2-background-bg-layer-01))" + element.append(probe) + const color = getComputedStyle(probe).backgroundColor + probe.remove() + return color }) - const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project") + await expect(emptyCard).toHaveCSS("background-color", emptyBackground) + const add = view.settings.getByRole("button", { name: "Add project", exact: true }) + await expect(add).toHaveCount(1) + await add.click() + const picker = page.getByRole("dialog", { name: "Open project", exact: true }) + await expect(picker).toBeVisible() + await picker.getByRole("button", { name: "Cancel", exact: true }).click() + await expect(picker).toBeHidden() +}) + +test("a single project uses the full grouped hover surface", async ({ page }) => { + const view = ui(page) + await view.settings.getByRole("tab", { name: "Projects", exact: true }).click() + const list = view.settings.getByRole("list") + const project = list.getByRole("listitem") + await expect(project).toHaveCount(1) + await expect + .poll(() => + project.evaluate((item) => { + const row = item.getBoundingClientRect() + const group = item.parentElement?.parentElement?.getBoundingClientRect() + if (!group) return [] + return [row.top - group.top, row.left - group.left, group.right - row.right, group.bottom - row.bottom] + }), + ) + .toEqual([0, 0, 0, 0]) +}) + +test("Projects search renders with a qualifying persisted inventory", async ({ page }) => { + const projects = projectList(8) + await page.route("**/api/project", (route) => route.fulfill({ json: projects })) + await persistProjects(page, projects) 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 view.settings.getByRole("tab", { name: "Projects", exact: true }).click() + await expect(search).toBeVisible() + await expect(search).not.toBeFocused() await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8) }) @@ -265,11 +338,13 @@ test("qualified project results preserve query and selection on return", async ( }) test("returning from a project restores a scrolled result list", async ({ page }) => { + const projects = projectList(30) await page.route("**/api/project", (route) => route.fulfill({ - json: projectList(30), + json: projects, }), ) + await persistProjects(page, projects) await page.reload() const view = ui(page) await view.search.fill("OpenCode") @@ -371,14 +446,17 @@ test("multi-server results navigate to the named server and hide search in neste 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.addInitScript((server) => { + const value = JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}") + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + ...value, + list: [{ type: "http", displayName: "Build server", http: { url: server } }], + projects: { ...value.projects, [server]: [{ worktree: "/remote/opencode", expanded: true }] }, + }), + ) + }, server) await page.reload() const view = ui(page) await view.search.fill("MCPs") @@ -393,6 +471,13 @@ test("multi-server results navigate to the named server and hide search in neste 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 projects") + await view.results.getByRole("option").click() + await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible() + await expect(view.settings.getByRole("button", { name: "Add project", exact: true })).toBeVisible() + await expect(view.settings.getByRole("list").getByRole("listitem")).toHaveCount(1) + await expect(view.settings.getByRole("button", { name: "OpenCode", exact: true })).toBeVisible() + 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() diff --git a/packages/app/src/home/model.ts b/packages/app/src/home/model.ts index ab48eb661981..f93e4193c3db 100644 --- a/packages/app/src/home/model.ts +++ b/packages/app/src/home/model.ts @@ -5,6 +5,8 @@ import { useTabs } from "@/shell/tabs/tabs" import { toggleHomeProjectSelection } from "@/shell/layout/helpers" import { createEffect, createMemo, startTransition } from "solid-js" import type { SessionInfo } from "@opencode/client/promise" +import { pathKey } from "@/workspaces/path-key" +import { addProjects } from "./projects/add" export function createHomeController() { const layout = useLayout() @@ -41,6 +43,24 @@ export function createHomeController() { // Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees. void ctx.sync.worktrees.list(id).then(() => ctx.sync.worktrees.refresh(id)) }) + createEffect(() => { + // The project list is empty until the server store hydrates; clearing the restored + // selection against it would persist the loss. + if (!servers.hydrated()) return + const current = selection() + const directory = current.directory + if (!directory) return + const conn = servers.visible.find((conn) => ServerConnection.key(conn) === current.server) + if (!conn) return + if ( + global + .ensureServerCtx(conn) + .projects.list() + .some((project) => pathKey(project.worktree) === pathKey(directory)) + ) + return + setSelection({ server: current.server }) + }) function setSelection(next: HomeProjectSelection) { layout.home.setSelection(next) @@ -99,23 +119,8 @@ export function createHomeController() { setSelection(toggleHomeProjectSelection(selection(), key, directory)) }, add: (conn: ServerConnection.Any, directories: string[]) => { - const directory = directories[0] + const directory = addProjects(global.ensureServerCtx(conn), directories) if (!directory) return - const ctx = global.ensureServerCtx(conn) - directories.forEach((item) => { - if (ctx.projects.list().some((project) => project.worktree === item)) return - const location = { directory: item } - void ctx.sdk.api.file - .list({ path: ".", location }) - .then(async (files) => { - // TODO: Initialize empty directories when V2 exposes a native Git init API. - return ctx.sdk.api.location.get({ location }).then((result) => result.project) - }) - .then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id)) - .catch(() => undefined) - ctx.projects.open(item) - }) - ctx.projects.touch(directory) setSelection({ server: ServerConnection.key(conn), directory }) }, openNewSession: () => { diff --git a/packages/app/src/home/projects/add.ts b/packages/app/src/home/projects/add.ts new file mode 100644 index 000000000000..f088eb2e9855 --- /dev/null +++ b/packages/app/src/home/projects/add.ts @@ -0,0 +1,19 @@ +import type { ServerCtx } from "@/runtime/server/runtime" + +export function addProjects(context: ServerCtx, directories: string[]) { + const directory = directories[0] + if (!directory) return + + directories.forEach((item) => { + if (context.projects.list().some((project) => project.worktree === item)) return + const location = { directory: item } + void context.sdk.api.file + .list({ path: ".", location }) + .then(() => context.sdk.api.location.get({ location })) + .then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id)) + .catch(() => undefined) + context.projects.open(item) + }) + context.projects.touch(directory) + return directory +} diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 070c4e95c163..50028b1072b6 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -6,7 +6,7 @@ import { type LocalProject } from "@/shell/state/layout" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { ServerConnection } from "@/runtime/server/registry" -import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers" +import { closeHomeProject, displayName, errorMessage, homeProjectDirectories } from "@/shell/layout/helpers" import { Persist, persisted } from "@/runtime/persistence/storage" import { showToast } from "@/shell/notifications/toast" import { useDialog } from "@opencode/ui/context/dialog" @@ -17,6 +17,7 @@ import type { HomeController } from "../model" import { useGlobal } from "@/runtime/server/runtime" import { SessionTransfer } from "@opencode/schema/session-transfer" import { useSshAuthenticate } from "@/servers/ssh/authenticate" +import { revealProject } from "./reveal" export const HomeServersSchema = Schema.Struct({ collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)), @@ -43,7 +44,7 @@ export function createHomeProjectsController(home: HomeController) { } function canRevealProject(conn: ServerConnection.Any) { - return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) + return platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn) } function choose(conn: ServerConnection.Any) { @@ -167,13 +168,27 @@ export function createHomeProjectsController(home: HomeController) { }, canReveal: canRevealProject, reveal: (conn: ServerConnection.Any, project: LocalProject) => { - if (!platform.openPath || !canRevealProject(conn)) return - platform.openPath(project.worktree).catch((cause: unknown) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(cause, language.t("common.requestFailed")), - }), - ) + if (!platform.revealPath || !canRevealProject(conn)) return + const context = global.ensureServerCtx(conn) + void revealProject({ + directory: project.worktree, + reveal: platform.revealPath, + remove: context.projects.remove, + }) + .then((revealed) => { + if (revealed) return + showToast({ + variant: "error", + title: language.t("home.project.missing.title"), + description: language.t("home.project.missing.description", { name: displayName(project) }), + }) + }) + .catch((cause: unknown) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + ) }, }, utility: { diff --git a/packages/app/src/home/projects/reveal.test.ts b/packages/app/src/home/projects/reveal.test.ts new file mode 100644 index 000000000000..7d6834809488 --- /dev/null +++ b/packages/app/src/home/projects/reveal.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test" +import { revealProject } from "./reveal" + +describe("reveal project", () => { + test("keeps a project when its directory is revealed", async () => { + const removed: string[] = [] + expect( + await revealProject({ + directory: "/projects/current", + reveal: async () => true, + remove: (directory) => removed.push(directory), + }), + ).toBe(true) + expect(removed).toEqual([]) + }) + + test("removes a project when its directory no longer exists", async () => { + const removed: string[] = [] + expect( + await revealProject({ + directory: "/projects/missing", + reveal: async () => false, + remove: (directory) => removed.push(directory), + }), + ).toBe(false) + expect(removed).toEqual(["/projects/missing"]) + }) +}) diff --git a/packages/app/src/home/projects/reveal.ts b/packages/app/src/home/projects/reveal.ts new file mode 100644 index 000000000000..c386c67ed2f7 --- /dev/null +++ b/packages/app/src/home/projects/reveal.ts @@ -0,0 +1,9 @@ +export async function revealProject(input: { + directory: string + reveal: (directory: string) => Promise + remove: (directory: string) => void +}) { + if (await input.reveal(input.directory)) return true + input.remove(input.directory) + return false +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index c82892415a7f..6301dc222183 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -720,6 +720,8 @@ export const dict = { "home.projects": "Projects", "home.projects.all": "All projects", "home.project.add": "Add project", + "home.project.missing.title": "Project folder not found", + "home.project.missing.description": "{{name}} was removed because its folder no longer exists.", "home.recentlyClosed": "Recently closed", "home.server.collapse": "Collapse server projects", "home.server.expand": "Expand server projects", @@ -1057,8 +1059,10 @@ export const dict = { "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.description": "Manage projects and their settings", "settings.projects.empty": "No projects found", + "settings.projects.empty.title": "No projects yet", + "settings.projects.empty.description": "Create your first project to get started", "settings.projects.search.placeholder": "Search projects", "settings.projects.server.all": "All servers", "settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools", diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx index a8a14bc8eb92..eaf6dc5451b7 100644 --- a/packages/app/src/runtime/platform/platform.tsx +++ b/packages/app/src/runtime/platform/platform.tsx @@ -46,6 +46,9 @@ type PlatformBase = { /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ revealPath?(path: string): Promise + /** Return the local paths that still exist as directories; paths that cannot be checked are kept (desktop only) */ + checkDirectories?(paths: string[]): Promise + /** Restart the app */ restart(): Promise diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 87c0273b8e37..a1e76225a370 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -205,7 +205,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex canonicalLocalServer?: ServerConnection.Key servers?: Array }) => { - const [store, setStore, _] = persisted( + const [store, setStore, _, hydrated] = persisted( { ...Persist.global("server"), sync: true, @@ -263,6 +263,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex get visible() { return visibleServers() }, + // Named to avoid the context `ready` gate: consumers that derive from persisted project + // state wait on this, but the provider must not block first render on storage. + hydrated, isHidden(key: ServerConnection.Key) { return store.hidden[key] ?? false }, diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index f1f99b536470..0dcac36e036b 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode/ui/context" -import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js" +import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js" import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry" import { pathKey } from "@/workspaces/path-key" import { useServerHealth } from "@/runtime/server/health" @@ -14,6 +14,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage" import { createDesktopData } from "./data" import { ModelState } from "./persistence" import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "./errors" import { useSettings } from "@/settings/model" @@ -109,6 +110,7 @@ function createServerController( ) { const language = useLanguage() const settings = useSettings() + const platform = usePlatform() const connKey = ServerConnection.key(conn) const sdk = createServerSdkContext(conn, scope) const source = createData({ @@ -166,6 +168,27 @@ function createServerController( const isLocal = (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) + // Only the desktop can stat a local server's project directories without a server round-trip. + // Each directory is checked once per app run; the list is tracked so persisted projects are + // checked after the store hydrates. + const checked = new Set() + createEffect(() => { + if (!isLocal || !platform.checkDirectories) return + const directories = projects + .list() + .map((project) => project.worktree) + .filter((directory) => !checked.has(pathKey(directory))) + if (directories.length === 0) return + directories.forEach((directory) => checked.add(pathKey(directory))) + void platform + .checkDirectories(directories) + .then((available) => { + const keep = new Set(available.map(pathKey)) + batch(() => directories.filter((directory) => !keep.has(pathKey(directory))).forEach(projects.remove)) + }) + .catch(() => undefined) + }) + return { data, sdk, diff --git a/packages/app/src/session/requests/session-question-dock.tsx b/packages/app/src/session/requests/session-question-dock.tsx index 5b97d8f5f543..560cb78244a9 100644 --- a/packages/app/src/session/requests/session-question-dock.tsx +++ b/packages/app/src/session/requests/session-question-dock.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode/ui/button" import { IconButton } from "@opencode/ui/icon-button" +import { Tooltip } from "@opencode/ui/tooltip" import { DockPrompt } from "@opencode/session-ui/dock-prompt" import { Icon } from "@opencode/ui/icon" import { useSpring } from "@opencode/ui/motion-spring" @@ -13,8 +14,10 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/runtime/server/client" import { ScopedKey } from "@/runtime/server/scope" +import { useCommand } from "@/shell/commands/command" const cache = new Map() +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) type QuestionField = FormStringField | FormMultiselectField @@ -70,6 +73,7 @@ function Option(props: { export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => { const serverSDK = useServerSDK() const language = useLanguage() + const command = useCommand() const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id) const questions = createMemo(() => @@ -261,6 +265,8 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => })) const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) + const submitShortcut = () => (IS_MAC ? "⌘⏎" : `${language.t("common.key.ctrl")}+⏎`) + const backShortcut = () => (IS_MAC ? "⌘[" : `${language.t("common.key.alt")}+←`) const reply = (answer: FormAnswer) => { if (sending()) return @@ -351,6 +357,16 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => return } + const previous = IS_MAC + ? event.metaKey && !event.ctrlKey && !event.altKey && event.key === "[" + : event.altKey && !event.ctrlKey && !event.metaKey && event.key === "ArrowLeft" + if (previous) { + if (event.repeat) return + event.preventDefault() + back() + return + } + const mod = (event.metaKey || event.ctrlKey) && !event.altKey if (mod && event.key === "Enter") { if (event.repeat) return @@ -461,6 +477,19 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => if (!store.minimized) focus(pickFocus(tab)) } + command.register("session.question.back", () => [ + { + id: "session.question.back", + title: language.t("ui.common.back"), + keybind: IS_MAC ? "mod+[" : "alt+arrowleft", + hidden: true, + // Stay registered while sending so the shortcut does not fall through to history navigation. + disabled: store.tab <= 0, + when: () => store.tab > 0, + onSelect: back, + }, + ]) + const minimize = () => { if (sending()) return setStore("editing", false) @@ -518,9 +547,25 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
0}> - + + {language.t("ui.common.back")} + {backShortcut()} + + } + > + +
diff --git a/packages/app/src/settings/keybinds/keybinds.tsx b/packages/app/src/settings/keybinds/keybinds.tsx index 3db42134cf35..8c21751ecad0 100644 --- a/packages/app/src/settings/keybinds/keybinds.tsx +++ b/packages/app/src/settings/keybinds/keybinds.tsx @@ -1,8 +1,7 @@ -import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js" +import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Button } from "@opencode/ui/button" -import { IconButton } from "@opencode/ui/icon-button" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" import fuzzysort from "fuzzysort" @@ -17,8 +16,6 @@ import { useLanguage } from "@/runtime/i18n/language" import { useSettings } from "@/settings/model" import { SettingsList } from "@/settings/list" -const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon }))) - const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" @@ -421,17 +418,9 @@ function SettingsKeybindsView(props: { autocomplete="off" autocapitalize="off" aria-label={language.t("settings.shortcuts.search.placeholder")} + showClearButton={!!store.filter} + onClearClick={() => setStore("filter", "")} /> - - } - onClick={() => setStore("filter", "")} - /> -
@@ -443,27 +432,29 @@ function SettingsKeybindsView(props: {

{language.t(groupKey[group])}

- {(id) => ( -
- {props.title(id)} - -
- )} + + {language.t("settings.shortcuts.pressKeys")} + + +
+ ) + }} diff --git a/packages/app/src/settings/search.css b/packages/app/src/settings/search.css index 2dcac30f69dc..63503ecf8c41 100644 --- a/packages/app/src/settings/search.css +++ b/packages/app/src/settings/search.css @@ -15,11 +15,12 @@ visibility: hidden; } -.settings-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] { +:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"] { color: var(--v2-icon-icon-faint); + cursor: default; } -.settings-search +:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-icon-button"][data-variant="clear"]:is(:hover, :active, :focus-visible):not(:disabled) { background: transparent; color: var(--v2-icon-icon-base); @@ -37,19 +38,20 @@ background: var(--v2-overlay-simple-overlay-hover); } -.settings-search [data-slot="text-input-v2-input"][data-overflow-start="true"] { +:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-start="true"] { --mask-start: 16px; } -.settings-search [data-slot="text-input-v2-input"][data-overflow-end="true"] { +:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"][data-overflow-end="true"] { --mask-end: 16px; } -.settings-search [data-slot="text-input-v2-input"]:dir(rtl) { +:is(.settings-search, .settings-projects-search) [data-slot="text-input-v2-input"]:dir(rtl) { --mask-direction: to left; } -.settings-search [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) { +:is(.settings-search, .settings-projects-search) + [data-slot="text-input-v2-input"]:is([data-overflow-start="true"], [data-overflow-end="true"]) { mask-image: linear-gradient( var(--mask-direction, to right), transparent, diff --git a/packages/app/src/settings/servers/inventory.ts b/packages/app/src/settings/servers/inventory.ts index b9ae3a0bf8a4..7ea7455877c8 100644 --- a/packages/app/src/settings/servers/inventory.ts +++ b/packages/app/src/settings/servers/inventory.ts @@ -5,17 +5,9 @@ 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 })), - ] + return context.projects.list() } export type SettingsServer = { diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index b33937ce9b68..ed2ba26eae1e 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -77,6 +77,8 @@ } .settings-nav > .settings-back { + position: relative; + inset-block-start: -20px; margin-block-end: 8px; } @@ -111,8 +113,8 @@ } @media (hover: hover) { - .settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:hover { - background: var(--v2-background-bg-layer-03); + .settings-nav-groups [data-slot="tabs-v2-trigger-wrapper"]:not(:has([data-selected])):hover { + background: var(--v2-overlay-simple-overlay-hover); } } @@ -803,6 +805,107 @@ padding-inline-end: 28px; } +.settings-projects-search [data-component="text-input-v2"] { + height: 36px; + background: color-mix(in oklab, var(--v2-background-bg-layer-02) 60%, transparent); + box-shadow: none; +} + +.settings-projects-search [data-component="text-input-v2"]:is(:hover, :focus-within):not([data-disabled]) { + background: var(--v2-background-bg-layer-02); +} + +.settings-projects-search [data-slot="text-input-v2-input"] { + padding-inline-end: 0; +} + +.settings-projects-search [data-slot="text-input-v2-leading-icon"] { + padding-inline-start: 16px; +} + +.settings-projects-search [data-slot="text-input-v2-value"] { + gap: 8px; +} + +.settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] { + margin-inline-end: -4px; +} + +.settings-projects-empty { + width: 60%; + margin-inline: auto; +} + +.settings-projects-empty .settings-search-empty { + justify-content: center; + padding-inline: 0; +} + +.settings-projects-empty .settings-search-empty-quoted { + flex: 0 1 auto; +} + +.settings-project-empty-card { + width: 100%; + border-radius: 8px; + background: color-mix( + in oklab, + var(--v2-background-bg-base) 50%, + var(--v2-background-bg-layer-01) + ); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); +} + +.settings-project-list { + width: 100%; + overflow: hidden; + border-radius: 8px; + background: var(--v2-background-bg-layer-01); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); +} + +.settings-project-row-shell { + position: relative; + margin-inline: 4px; + padding-block: 4px; +} + +.settings-project-row-shell:not(:last-child)::after { + content: ""; + position: absolute; + inset-inline: 12px; + bottom: 0; + height: 0.5px; + background: var(--v2-border-border-base); +} + +.settings-project-row-shell:only-child { + margin-inline: 0; + padding-block: 0; +} + +.settings-project-row-shell:only-child .settings-project-card { + height: 48px; + padding-inline: 16px; + border-radius: 8px; +} + +.settings-project-card { + display: flex; + height: 40px; + align-items: center; + gap: 8px; + padding-inline: 12px; + border-radius: 4px; + transition: background-color 120ms; +} + +.settings-project-card:hover, +.settings-project-card:focus-within, +.settings-project-card[data-menu="true"] { + background: var(--v2-background-bg-layer-02); +} + .settings-tab-search-clear { position: absolute; top: 50%; @@ -960,43 +1063,6 @@ font-variation-settings: "slnt" 0; } -.settings-keybind-button { - box-sizing: border-box; - flex-shrink: 0; - padding: 6px 8px; - margin: -6px -8px; - border: 0; - border-radius: 2px; - background: transparent; - cursor: pointer; - font-style: normal; - font-weight: 530; - font-size: 11px; - line-height: 1; - letter-spacing: 0.05px; - font-variant-numeric: tabular-nums; - font-feature-settings: - "tnum" on, - "lnum" on; - font-variation-settings: "slnt" 0; - color: var(--v2-text-text-faint); -} - -.settings-keybind-button:hover { - background-color: var(--v2-background-bg-layer-02); -} - -.settings-keybind-button:focus-visible { - outline: 2px solid var(--v2-border-border-focus); - outline-offset: 2px; -} - -.settings-keybind-button--active { - color: var(--v2-text-text-faint); - border-radius: 2px; - background-color: var(--v2-background-bg-layer-02); -} - .settings-shortcuts-status { display: flex; flex-direction: column; diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 2f39054554be..0f85611529d3 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -5,7 +5,6 @@ import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" import { useTabs } from "@/shell/tabs/tabs" -import { displayName } from "@/shell/layout/helpers" import { useGlobal, useServerCtx } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" import type { LocalProject } from "@/shell/state/layout" @@ -294,8 +293,6 @@ function RootSettings() { surface.openProject({ server: ServerConnection.key(server), @@ -378,7 +375,6 @@ function ServerSettings(props: { entry: SettingsServer }) { surface.openProject({ server: props.entry.key, @@ -421,10 +417,8 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr { items: nestedProjectTabs.map((item) => ({ ...item, + label: language.t(item.label), onPrefetch: item.value === "workspaces" ? prefetchWorkspaces : undefined, - get label() { - return item.value === "general" ? displayName(props.project) : language.t(item.label) - }, })), }, ] @@ -443,6 +437,7 @@ function ProjectSettings(props: { server: ServerConnection.Any; project: LocalPr server={props.server} project={props.project} onOpenServer={() => surface.replaceServer(ServerConnection.key(props.server))} + onClose={() => surface.back()} /> diff --git a/packages/app/src/settings/timeline-detail.css b/packages/app/src/settings/timeline-detail.css index 1d1cfaab19bc..b1e1605728a9 100644 --- a/packages/app/src/settings/timeline-detail.css +++ b/packages/app/src/settings/timeline-detail.css @@ -24,7 +24,7 @@ [data-slot="timeline-detail-scale"] { position: relative; height: 28px; - margin-top: 24px; + margin-top: 16px; margin-inline: 8px; } @@ -58,6 +58,10 @@ border-radius: 50%; background: var(--timeline-detail-marker-background); + &[data-selected] { + background: var(--v2-background-bg-accent); + } + &:dir(rtl) { transform: translate(50%, -50%); } @@ -74,7 +78,6 @@ margin: 0; margin-inline: -8px; background: transparent; - cursor: pointer; border-radius: 4px; &:focus-visible { @@ -127,7 +130,7 @@ } [data-slot="timeline-detail-advanced"] { - margin-top: 20px; + margin-top: 0; padding-top: 8px; } @@ -233,7 +236,7 @@ [data-slot="timeline-detail-activity"] { display: flex; align-items: center; - gap: 12px; + gap: 8px; min-width: 0; > label { @@ -257,6 +260,24 @@ } } +@container timeline-detail (min-width: 311px) { + [data-component="timeline-detail-control"] + [data-slot="timeline-detail-advanced"]:has(> [data-slot="collapsible-trigger"][aria-expanded="true"]) { + > [data-slot="collapsible-trigger"] { + position: relative; + z-index: 1; + } + + > [data-slot="collapsible-content"] { + margin-top: -24px; + } + + [data-slot="timeline-detail-columns"] { + min-height: 24px; + } + } +} + [data-color-scheme="dark"] [data-component="timeline-detail-control"] { --timeline-detail-track-background: var(--v2-background-bg-layer-03); --timeline-detail-marker-background: diff --git a/packages/app/src/settings/timeline-detail.tsx b/packages/app/src/settings/timeline-detail.tsx index 05a03bf2037c..a05ecd9b85b8 100644 --- a/packages/app/src/settings/timeline-detail.tsx +++ b/packages/app/src/settings/timeline-detail.tsx @@ -51,7 +51,12 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange: style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }} > - {(_, index) => } + {(_, index) => ( + + )} void + onEdit?: () => void + onRename?: () => void + onClose?: () => void + class?: string + size?: "small" | "normal" | "large" +}> = (props) => { + const language = useLanguage() + const global = useGlobal() + const platform = usePlatform() + const context = () => global.ensureServerCtx(props.server) + const canReveal = () => + platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(props.server) + const unseen = () => + [props.project.worktree, ...(props.project.sandboxes ?? [])].reduce( + (total, directory) => total + context().notification.project.unseenCount(directory), + 0, + ) + const reveal = () => { + if (!platform.revealPath || !canReveal()) return + void revealProject({ + directory: props.project.worktree, + reveal: platform.revealPath, + remove: context().projects.remove, + }) + .then((revealed) => { + if (revealed) return + showToast({ + variant: "error", + title: language.t("home.project.missing.title"), + description: language.t("home.project.missing.description", { name: displayName(props.project) }), + }) + }) + .catch((cause: unknown) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + ) + } + const clearNotifications = () => { + const notification = context().notification + const directories = [props.project.worktree, ...(props.project.sandboxes ?? [])] + directories + .filter((directory) => notification.project.unseenCount(directory) > 0) + .forEach((directory) => notification.project.markViewed(directory)) + } + const close = () => { + context().projects.close(props.project.worktree) + props.onClose?.() + } + + return ( + + } + aria-label={language.t("common.moreOptions")} + /> + + + + {(edit) => {language.t("common.edit")}} + + + {(rename) => {language.t("common.rename")}} + + + {language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)} + + + {language.t("sidebar.project.clearNotifications")} + + + {language.t("common.close")} + + + + ) +} diff --git a/packages/app/src/settings/workspaces/project-row.tsx b/packages/app/src/settings/workspaces/project-row.tsx new file mode 100644 index 000000000000..2918431b0c58 --- /dev/null +++ b/packages/app/src/settings/workspaces/project-row.tsx @@ -0,0 +1,135 @@ +import { Show } from "solid-js" +import { createStore } from "solid-js/store" +import { InlineInput } from "@opencode/ui/inline-input" +import { getFilename } from "@opencode/util/path" +import { useLanguage } from "@/runtime/i18n/language" +import { useGlobal } from "@/runtime/server/runtime" +import { ServerConnection } from "@/runtime/server/registry" +import type { LocalProject } from "@/shell/state/layout" +import { displayName, errorMessage } from "@/shell/layout/helpers" +import { ProjectIcon } from "@/shell/layout/project-icon" +import { showToast } from "@/shell/notifications/toast" +import { ProjectOptions } from "./project-options" + +export function SettingsProjectRow(props: { + project: LocalProject + server: ServerConnection.Any + onOpen: (project: LocalProject) => void +}) { + const language = useLanguage() + const global = useGlobal() + const [store, setStore] = createStore({ + menu: false, + editor: undefined as { draft: string; saving: boolean } | undefined, + }) + let button: HTMLButtonElement | undefined + let input: HTMLInputElement | undefined + const openEditor = () => { + setStore("editor", { draft: displayName(props.project), saving: false }) + requestAnimationFrame(() => { + input?.focus() + input?.select() + }) + } + const closeEditor = () => { + if (store.editor?.saving) return + setStore("editor", undefined) + } + const saveEditor = async () => { + if (!store.editor || store.editor.saving) return + const name = store.editor.draft.trim() + if (!name || name === displayName(props.project)) { + closeEditor() + requestAnimationFrame(() => button?.focus()) + return + } + setStore("editor", "saving", true) + const context = global.ensureServerCtx(props.server) + const value = name === getFilename(props.project.worktree) ? "" : name + const saved = await (props.project.id && props.project.id !== "global" + ? context.sdk.api.project + .update({ projectID: props.project.id, name: value }) + .then((project) => context.sync.project.update(project)) + : Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value })) + ) + .then(() => true) + .catch((cause: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) + return false + }) + const restore = document.activeElement === document.body || document.activeElement === input + if (saved) setStore("editor", undefined) + if (!saved) setStore("editor", "saving", false) + if (!restore) return + requestAnimationFrame(() => (saved ? button : input)?.focus()) + } + + return ( +
+
+ + + setStore("editor", "draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.isComposing || event.keyCode === 229) return + if (event.key === "Enter") { + event.preventDefault() + void saveEditor() + return + } + if (event.key !== "Escape") return + event.preventDefault() + closeEditor() + requestAnimationFrame(() => button?.focus()) + }} + onBlur={closeEditor} + /> +
+ } + > + + setStore("menu", open)} + onEdit={() => props.onOpen(props.project)} + onRename={openEditor} + /> + +
+ + ) +} diff --git a/packages/app/src/settings/workspaces/project.tsx b/packages/app/src/settings/workspaces/project.tsx index 5d5d88308bf3..931c974afb5f 100644 --- a/packages/app/src/settings/workspaces/project.tsx +++ b/packages/app/src/settings/workspaces/project.tsx @@ -9,6 +9,7 @@ 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 { ProjectOptions } from "./project-options" import { SettingsList } from "@/settings/list" import { SettingsRow } from "@/settings/row" import { createEditProjectModel } from "./project-model" @@ -18,6 +19,7 @@ export const SettingsProjectGeneral: Component<{ project: LocalProject server: ServerConnection.Any onOpenServer: () => void + onClose: () => void }> = (props) => { const language = useLanguage() const model = createEditProjectModel(props) @@ -27,16 +29,29 @@ export const SettingsProjectGeneral: Component<{ <>
-
-

- {model.store.name || displayName(props.project)} -

- 1}> - - +
+ +
+

+ {model.store.name || displayName(props.project)} +

+ 1}> + + +
+
diff --git a/packages/app/src/settings/workspaces/projects.tsx b/packages/app/src/settings/workspaces/projects.tsx index d002ab8fdec1..80d91f6e9fe1 100644 --- a/packages/app/src/settings/workspaces/projects.tsx +++ b/packages/app/src/settings/workspaces/projects.tsx @@ -1,48 +1,70 @@ -import { For, Show, createEffect, createMemo, on, onCleanup, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, on, type Component } from "solid-js" import { createStore } from "solid-js/store" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { Button } from "@opencode/ui/button" import { Icon } from "@opencode/ui/icon" import { TextInput } from "@opencode/ui/text-input" import { useLanguage } from "@/runtime/i18n/language" import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" -import { displayName } from "@/shell/layout/helpers" -import { ProjectIcon } from "@/shell/layout/project-icon" +import { displayName, homeProjectDirectories } from "@/shell/layout/helpers" import type { LocalProject } from "@/shell/state/layout" +import { useDirectoryPicker } from "@/workspaces/selection/picker" +import { addProjects } from "@/home/projects/add" import { settingsProjects } from "../servers/inventory" +import { SettingsSearchEmpty } from "../search-empty" +import { SettingsProjectRow } from "./project-row" +import "@/settings/search.css" import "@/settings/settings.css" export const SettingsProjects: Component<{ server: ServerConnection.Any - active?: boolean - autofocus?: boolean onOpenProject: (project: LocalProject) => void }> = (props) => { const language = useLanguage() const global = useGlobal() - const [store, setStore] = createStore({ filter: "" }) + const pickDirectory = useDirectoryPicker() + const [store, setStore] = createStore({ + filter: "", + overflow: { start: false, end: false }, + }) let search: HTMLInputElement | undefined - const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server))) + const updateOverflow = () => { + if (!search) return + const offset = Math.abs(search.scrollLeft) + setStore("overflow", { + start: offset > 1, + end: search.scrollWidth - search.clientWidth - offset > 1, + }) + } + createEffect(on(() => store.filter, updateOverflow)) + const context = createMemo(() => global.ensureServerCtx(props.server)) + const projects = createMemo(() => settingsProjects(context())) 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() }) - 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)) - }, - ), - ) createEffect(() => { if (!searchable()) setStore("filter", "") }) + const addProject = () => + pickDirectory({ + server: props.server, + title: language.t("command.project.open"), + multiple: true, + onSelect: (result) => { + const directories = homeProjectDirectories(result) + const directory = addProjects(context(), directories) + if (!directory) return + if (directories.length > 1) return + const project = context() + .projects.list() + .find((item) => item.worktree === directory) + if (!project) return + props.onOpenProject(project) + }, + }) return ( <> @@ -52,18 +74,31 @@ export const SettingsProjects: Component<{

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

{language.t("settings.projects.description")}
+ 0}> + + -