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 00000000000..93e134a860b --- /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 09fafdf1dc9..a391bf489a5 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 846bad64b52..aa78331109b 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 8513fe172ed..8cdc1303ff0 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 73daf5881bf..dceba4b09f3 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 b9e48a7b05c..66ffc13ba60 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,6 +193,117 @@ 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 }) => { + 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") + 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).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 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).not.toBeFocused() + await search.fill("OpenCode 06") + await projects.click() + await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible() + }) +} + +test("Projects shows an add action in its empty state", async ({ page }) => { + await page.route("**/api/project", (route) => route.fulfill({ json: [] })) + 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("Add a 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 + }) + 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() + const view = ui(page) + const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true }) + 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) +}) + 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)) { @@ -207,11 +339,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") @@ -313,14 +447,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") @@ -335,6 +472,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 ab48eb66198..f93e4193c3d 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 00000000000..f088eb2e985 --- /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 070c4e95c16..4e1d0cb696d 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -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 { useRevealProject } from "./reveal" export const HomeServersSchema = Schema.Struct({ collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)), @@ -32,6 +33,7 @@ export function createHomeProjectsController(home: HomeController) { const serverManagement = useServerActionsController() const global = useGlobal() const authenticate = useSshAuthenticate() + const revealProject = useRevealProject() const [_state, setState, _, ready] = persisted(Persist.global("home.servers"), HomeServersSchema, { collapsed: {} }) const [state] = createResource( () => ready.promise ?? Promise.resolve(), @@ -42,10 +44,6 @@ export function createHomeProjectsController(home: HomeController) { return [project.worktree, ...(project.sandboxes ?? [])] } - function canRevealProject(conn: ServerConnection.Any) { - return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) - } - function choose(conn: ServerConnection.Any) { pickDirectory({ server: conn, @@ -165,16 +163,8 @@ export function createHomeProjectsController(home: HomeController) { move: (conn: ServerConnection.Any, worktree: string, index: number) => { home.server.context(conn).projects.move(worktree, index) }, - 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")), - }), - ) - }, + canReveal: revealProject.available, + reveal: revealProject.reveal, }, utility: { settings: openSettings, diff --git a/packages/app/src/home/projects/reveal.ts b/packages/app/src/home/projects/reveal.ts new file mode 100644 index 00000000000..6cad65ec863 --- /dev/null +++ b/packages/app/src/home/projects/reveal.ts @@ -0,0 +1,36 @@ +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" +import { ServerConnection } from "@/runtime/server/registry" +import { displayName, errorMessage } from "@/shell/layout/helpers" +import type { LocalProject } from "@/shell/state/layout" +import { showToast } from "@/shell/notifications/toast" + +export function useRevealProject() { + const language = useLanguage() + const platform = usePlatform() + const available = (conn: ServerConnection.Any) => + platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn) + + return { + available, + reveal(conn: ServerConnection.Any, project: LocalProject) { + if (!platform.revealPath || !available(conn)) return + void platform + .revealPath(project.worktree) + .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")), + }), + ) + }, + } +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index cb3bed0d006..3367c64b39f 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -797,6 +797,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": "The folder for {{name}} no longer exists.", "home.recentlyClosed": "Recently closed", "home.server.collapse": "Collapse server projects", "home.server.expand": "Expand server projects", @@ -1152,8 +1154,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": "Add a 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/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index eb7ad29f212..fe968fd0423 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -200,7 +200,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, @@ -258,6 +258,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/session/requests/session-question-dock.tsx b/packages/app/src/session/requests/session-question-dock.tsx index 5b97d8f5f54..560cb78244a 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 ddabfb90aa2..bbc3c7ee72e 100644 --- a/packages/app/src/settings/keybinds/keybinds.tsx +++ b/packages/app/src/settings/keybinds/keybinds.tsx @@ -2,8 +2,6 @@ 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 { Icon } from "@opencode/ui/icon" -import { IconButton } from "@opencode/ui/icon-button" import { TextInput } from "@opencode/ui/text-input" import { showToast } from "@/shell/notifications/toast" import fuzzysort from "fuzzysort" @@ -420,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", "")} - /> -
@@ -442,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/settings.css b/packages/app/src/settings/settings.css index 61a37447f51..6958fbde1f2 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -42,7 +42,7 @@ flex-shrink: 0; width: 328px; min-width: 328px; - padding-block: 70px 48px; + padding-block: 50px 48px; padding-inline-start: 24px; padding-inline-end: 64px; border-inline-end: none; @@ -77,7 +77,7 @@ } .settings-nav > .settings-back { - margin-block-end: 8px; + margin-block-end: 28px; } .settings-nav-groups { @@ -111,8 +111,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); } } @@ -950,7 +950,7 @@ } .settings-projects-search [data-slot="text-input-v2-value"] { - gap: 12px; + gap: 8px; } .settings-projects-search [data-slot="text-input-v2-icon-button"][data-variant="clear"] { @@ -971,38 +971,66 @@ flex: 0 1 auto; } -.settings-project-row { - position: relative; - margin-inline: 1px; +.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-row:hover { +.settings-project-card:hover, +.settings-project-card:focus-within, +.settings-project-card[data-menu="true"] { background: var(--v2-background-bg-layer-02); } - -.settings-project-row-content { - display: flex; - width: 100%; - min-height: 73px; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 16px; - border-radius: inherit; - text-align: start; -} - -.settings-project-row-name { - font-size: 13px; - font-weight: 530; - line-height: var(--line-height-compact); - color: var(--v2-text-text-base); -} - .settings-tab-search-clear { position: absolute; top: 50%; @@ -1319,43 +1347,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 b394a71faf3..2c13f403865 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -6,7 +6,6 @@ import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" 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" @@ -456,10 +455,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) - }, })), }, ] @@ -478,6 +475,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 1d1cfaab19b..b1e1605728a 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 05a03bf2037..a05ecd9b85b 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 revealProject = useRevealProject() + const context = () => global.ensureServerCtx(props.server) + const unseen = () => + [props.project.worktree, ...(props.project.sandboxes ?? [])].reduce( + (total, directory) => total + context().notification.project.unseenCount(directory), + 0, + ) + 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")}} + + + revealProject.reveal(props.server, props.project)}>{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 index 3db6c6d32f7..485b36c7a06 100644 --- a/packages/app/src/settings/workspaces/project-row.tsx +++ b/packages/app/src/settings/workspaces/project-row.tsx @@ -1,18 +1,15 @@ -import { Show, type JSX } from "solid-js" +import { Show } from "solid-js" import { createStore } from "solid-js/store" -import { Icon } from "@opencode/ui/icon" import { InlineInput } from "@opencode/ui/inline-input" -import { Menu } from "@opencode/ui/menu" import { getFilename } from "@opencode/util/path" import { useLanguage } from "@/runtime/i18n/language" -import { usePlatform } from "@/runtime/platform/platform" -import { ServerConnection } from "@/runtime/server/registry" 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 { fileManagerApp } from "@/home/projects/file-manager" import { ProjectIcon } from "@/shell/layout/project-icon" import { showToast } from "@/shell/notifications/toast" -import type { LocalProject } from "@/shell/state/layout" +import { ProjectOptions } from "./project-options" export function SettingsProjectRow(props: { project: LocalProject @@ -20,21 +17,13 @@ export function SettingsProjectRow(props: { onOpen: (project: LocalProject) => void }) { const language = useLanguage() - const platform = usePlatform() const global = useGlobal() const [store, setStore] = createStore({ - menu: undefined as { x: number; y: number } | undefined, + menu: false, editor: undefined as { draft: string; saving: boolean } | undefined, }) - let row: HTMLDivElement | undefined let button: HTMLButtonElement | undefined let input: HTMLInputElement | undefined - let outside = false - const openMenu = (x: number, y: number) => { - if (!row) return - const bounds = row.getBoundingClientRect() - setStore("menu", { x: x - bounds.left, y: y - bounds.top }) - } const openEditor = () => { setStore("editor", { draft: displayName(props.project), saving: false }) requestAnimationFrame(() => { @@ -64,11 +53,11 @@ export function SettingsProjectRow(props: { : Promise.resolve(context.sync.project.meta(props.project.worktree, { name: value })) ) .then(() => true) - .catch((error: unknown) => { + .catch((cause: unknown) => { showToast({ variant: "error", title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), }) return false }) @@ -80,29 +69,26 @@ export function SettingsProjectRow(props: { } return ( -
{ - if (store.editor) return - event.preventDefault() - openMenu(event.clientX, event.clientY) - }} - > - - +
+
+ + setStore("editor", "draft", event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() @@ -119,112 +105,32 @@ export function SettingsProjectRow(props: { }} onBlur={closeEditor} /> - -
- } - > -
+ } > - - {displayName(props.project)} - - - -
- { - if (!open) setStore("menu", undefined) - }} - > - + + + {displayName(props.project)} + + + setStore("menu", open)} + onEdit={() => props.onOpen(props.project)} + onRename={openEditor} + /> + +
) } - -function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) { - return ( - - - - {props.children} - - {props.project.worktree} - - - - ) -} diff --git a/packages/app/src/settings/workspaces/project.tsx b/packages/app/src/settings/workspaces/project.tsx index 5d5d88308bf..931c974afb5 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 3561255fa12..2c97e47fe1a 100644 --- a/packages/app/src/settings/workspaces/projects.tsx +++ b/packages/app/src/settings/workspaces/projects.tsx @@ -2,15 +2,18 @@ import { Show, createEffect, createMemo, on, type Component } from "solid-js" import { Key } from "@solid-primitives/keyed" 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 { displayName, homeProjectDirectories } from "@/shell/layout/helpers" import type { LocalProject } from "@/shell/state/layout" -import { SettingsSearchEmpty } from "../search-empty" +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" @@ -21,7 +24,11 @@ export const SettingsProjects: Component<{ }> = (props) => { const language = useLanguage() const global = useGlobal() - const [store, setStore] = createStore({ filter: "", overflow: { start: false, end: false } }) + const pickDirectory = useDirectoryPicker() + const [store, setStore] = createStore({ + filter: "", + overflow: { start: false, end: false }, + }) let search: HTMLInputElement | undefined const updateOverflow = () => { if (!search) return @@ -32,7 +39,8 @@ export const SettingsProjects: Component<{ }) } createEffect(on(() => store.filter, updateOverflow)) - const projects = createMemo(() => settingsProjects(global.ensureServerCtx(props.server))) + 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() : "" @@ -41,6 +49,23 @@ export const SettingsProjects: Component<{ 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 ( <> @@ -50,6 +75,11 @@ export const SettingsProjects: Component<{

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

{language.t("settings.projects.description")}
+ 0}> + + + + {language.t("settings.projects.empty")} + + } + > +
+ +
+
} > -
- +
+ +
+ {language.t("settings.projects.empty.title")} +
+
+ {language.t("settings.projects.empty.description")} +
+
} > -
+
{(project) => (