feat(desktop): improve settings project management (#49172)

Co-authored-by: David Hill <iamdavidhill@gmail.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1
2026-09-24 06:23:15 +02:00
committed by GitHub
parent f0aff6cfe8
commit 95cfca73b0
22 changed files with 827 additions and 327 deletions
@@ -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()
})
@@ -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 {
@@ -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",
@@ -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(
@@ -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()
})
})
}
@@ -30,6 +30,19 @@ function projectList(count: number) {
}))
}
async function persistProjects(page: Page, projects: ReturnType<typeof projectList>) {
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()
+21 -16
View File
@@ -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: () => {
+19
View File
@@ -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
}
+4 -14
View File
@@ -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,
+36
View File
@@ -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")),
}),
)
},
}
}
+5 -1
View File
@@ -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",
+4 -1
View File
@@ -200,7 +200,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
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
},
@@ -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<string, { tab: number; answers: string[][]; custom: string[]; customOn: boolean[] }>()
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: () =>
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="neutral" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
<Tooltip
placement="top"
value={
<>
{language.t("ui.common.back")}
<span class="opacity-60">{backShortcut()}</span>
</>
}
>
<Button
variant="neutral"
size="large"
disabled={sending()}
onClick={back}
aria-keyshortcuts={IS_MAC ? "Meta+[" : "Alt+ArrowLeft"}
>
{language.t("ui.common.back")}
</Button>
</Tooltip>
</Show>
<Button
variant={last() ? "submit" : "neutral"}
@@ -530,6 +575,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
<span data-slot="question-submit-shortcut" aria-hidden="true" class="text-11-medium opacity-60">
{submitShortcut()}
</span>
</Button>
</div>
</>
+24 -32
View File
@@ -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", "")}
/>
<Show when={store.filter}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-tab-body">
@@ -442,27 +432,29 @@ function SettingsKeybindsView(props: {
<h3 class="settings-section-title">{language.t(groupKey[group])}</h3>
<SettingsList>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-keybind-button": true,
"settings-keybind-button--active": props.active === id,
}}
onClick={() => props.onCapture(id)}
>
<Show
when={props.active === id}
fallback={props.keybind(id) || language.t("settings.shortcuts.unassigned")}
{(id) => {
const binding = () => props.keybind(id)
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<Button
type="button"
size="small"
variant={binding() ? "ghost" : "ghost-muted"}
data-keybind-id={id}
data-expanded={props.active === id ? "" : undefined}
onClick={() => props.onCapture(id)}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
<Show
when={props.active === id}
fallback={binding() || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</Button>
</div>
)
}}
</For>
</SettingsList>
</div>
+57 -66
View File
@@ -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;
+2 -4
View File
@@ -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()}
/>
</Tabs.Content>
<Tabs.Content value="workspaces" class="settings-panel">
+25 -4
View File
@@ -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:
@@ -51,7 +51,12 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
{(_, index) => (
<span
data-selected={index() <= position() ? "" : undefined}
style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }}
/>
)}
</For>
</div>
<input
@@ -0,0 +1,82 @@
import { Show, type Component } from "solid-js"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import type { LocalProject } from "@/shell/state/layout"
import { fileManagerApp } from "@/home/projects/file-manager"
import { useRevealProject } from "@/home/projects/reveal"
export const ProjectOptions: Component<{
server: ServerConnection.Any
project: LocalProject
open?: boolean
onOpenChange?: (open: boolean) => 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 (
<Menu
gutter={4}
modal={false}
placement="bottom-end"
open={props.open}
onOpenChange={props.onOpenChange}
>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
size={props.size ?? "small"}
class={props.class}
icon={<Icon name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<Menu.Portal>
<Menu.Content>
<Show when={props.onEdit} keyed>
{(edit) => <Menu.Item onSelect={edit}>{language.t("common.edit")}</Menu.Item>}
</Show>
<Show when={props.onRename} keyed>
{(rename) => <Menu.Item onSelect={rename}>{language.t("common.rename")}</Menu.Item>}
</Show>
<Show when={revealProject.available(props.server)}>
<Menu.Item onSelect={() => revealProject.reveal(props.server, props.project)}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}</Menu.Item>
</Show>
<Menu.Item disabled={unseen() === 0} onSelect={clearNotifications}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={close}>{language.t("common.close")}</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
@@ -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 (
<div
ref={row}
data-component="settings-project-row"
class="settings-project-row group"
onContextMenu={(event) => {
if (store.editor) return
event.preventDefault()
openMenu(event.clientX, event.clientY)
}}
>
<Show
when={!store.editor}
fallback={
<div class="settings-project-row-content">
<ProjectRowContent project={props.project}>
<div class="settings-project-row-shell">
<div
role="listitem"
data-component="settings-project-card"
data-menu={store.menu ? "true" : undefined}
class="settings-project-card"
>
<Show
when={!store.editor}
fallback={
<div class="flex h-full min-w-0 flex-1 items-center gap-2">
<ProjectIcon project={props.project} class="shrink-0" />
<InlineInput
ref={input}
aria-label={language.t("common.rename")}
dir="auto"
value={store.editor?.draft ?? ""}
disabled={store.editor?.saving}
class="settings-project-row-name w-full outline-none"
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
class="w-full text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none"
style={{ "--inline-input-shadow": "none", "border-radius": "0", "text-align": "start" }}
onInput={(event) => setStore("editor", "draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
@@ -119,112 +105,32 @@ export function SettingsProjectRow(props: {
}}
onBlur={closeEditor}
/>
</ProjectRowContent>
</div>
}
>
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
aria-haspopup="menu"
aria-expanded={!!store.menu}
class="settings-project-row-content"
onClick={() => props.onOpen(props.project)}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(bounds.left + 12, bounds.bottom)
}}
</div>
}
>
<ProjectRowContent project={props.project}>
<bdi class="settings-project-row-name truncate">{displayName(props.project)}</bdi>
</ProjectRowContent>
<Icon
name="chevron-right"
size="small"
class="shrink-0 text-v2-icon-icon-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!store.menu}
onOpenChange={(open) => {
if (!open) setStore("menu", undefined)
}}
>
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${store.menu?.x ?? 0}px`, top: `${store.menu?.y ?? 0}px` }}
/>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
outside = true
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
const restore = !outside && !store.editor
outside = false
if (restore) requestAnimationFrame(() => button?.focus())
}}
<button
ref={button}
type="button"
aria-label={displayName(props.project)}
title={props.project.worktree}
class="flex h-full min-w-0 flex-1 items-center gap-2 rounded-[4px] bg-transparent text-start focus-visible:outline-none focus-visible:[box-shadow:inset_0_0_0_1px_var(--v2-border-border-focus)]"
onClick={() => props.onOpen(props.project)}
>
<Menu.Item onSelect={openEditor}>{language.t("common.rename")}</Menu.Item>
<Show
when={platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)}
>
<Menu.Item
onSelect={() => {
if (!platform.openPath) return
void platform.openPath(props.project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}}
>
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item
onSelect={() => {
const next = row?.nextElementSibling ?? row?.previousElementSibling
global.ensureServerCtx(props.server).projects.close(props.project.worktree)
requestAnimationFrame(() => next?.querySelector("button")?.focus())
}}
>
{language.t("common.close")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<ProjectIcon project={props.project} class="shrink-0" />
<bdi class="truncate text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base">
{displayName(props.project)}
</bdi>
</button>
<ProjectOptions
server={props.server}
project={props.project}
open={store.menu}
onOpenChange={(open) => setStore("menu", open)}
onEdit={() => props.onOpen(props.project)}
onRename={openEditor}
/>
</Show>
</div>
</div>
)
}
function ProjectRowContent(props: { project: LocalProject; children: JSX.Element }) {
return (
<span class="flex items-start gap-2.5 min-w-0 flex-1">
<ProjectIcon project={props.project} class="shrink-0" />
<span class="flex min-w-0 flex-1 flex-col gap-1.5">
{props.children}
<bdi
dir="ltr"
class="text-11-regular leading-[var(--line-height-compact)] text-v2-text-text-muted truncate"
title={props.project.worktree}
>
{props.project.worktree}
</bdi>
</span>
</span>
)
}
@@ -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<{
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
<div class="flex min-w-0 items-center gap-3">
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
}}
class="!size-8 shrink-0 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[16px]"
/>
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title truncate">
<bdi dir="auto">{model.store.name || displayName(props.project)}</bdi>
</h2>
<Show when={servers().length > 1}>
<button type="button" class="project-settings-server-link" onClick={() => props.onOpenServer()}>
<bdi dir="auto">{serverName(props.server) || ServerConnection.key(props.server)}</bdi>
</button>
</Show>
</div>
</div>
<ProjectOptions server={props.server} project={props.project} size="large" onClose={props.onClose} />
</div>
</div>
@@ -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<{
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<Show when={projects().length > 0}>
<Button variant="ghost-muted" icon="plus" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</Show>
</div>
<Show when={searchable()}>
<div class="settings-tab-search settings-projects-search">
@@ -88,20 +118,41 @@ export const SettingsProjects: Component<{
when={filtered().length > 0}
fallback={
<Show
when={store.filter.trim()}
when={!store.filter.trim() && projects().length === 0}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
<Show
when={store.filter.trim()}
fallback={
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
{language.t("settings.projects.empty")}
</div>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
</div>
</Show>
}
>
<div class="settings-projects-empty">
<SettingsSearchEmpty query={store.filter} />
<div
data-component="settings-project-empty-card"
class="settings-project-empty-card flex flex-col items-center gap-2 py-24 text-center"
>
<Icon name="folder" size="large" class="mb-2 text-v2-icon-icon-muted" />
<div class="text-13-medium text-v2-text-text-base">
{language.t("settings.projects.empty.title")}
</div>
<div class="text-13-regular text-v2-text-text-muted">
{language.t("settings.projects.empty.description")}
</div>
<Button variant="neutral" icon="plus" class="mt-6" onClick={addProject}>
{language.t("home.project.add")}
</Button>
</div>
</Show>
}
>
<div class="flex w-full flex-col gap-2">
<div role="list" class="settings-project-list">
<Key each={filtered()} by="worktree">
{(project) => (
<SettingsProjectRow project={project()} server={props.server} onOpen={props.onOpenProject} />