mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user