summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/e2e/actions.ts70
-rw-r--r--packages/app/e2e/projects/workspaces.spec.ts391
-rw-r--r--packages/app/e2e/selectors.ts9
-rw-r--r--packages/app/playwright.config.ts4
-rw-r--r--packages/app/src/pages/layout.tsx16
-rw-r--r--packages/opencode/src/session/index.ts7
-rw-r--r--packages/opencode/src/worktree/index.ts30
7 files changed, 517 insertions, 10 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 1eb2da1db..5f80d67c2 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -8,11 +8,15 @@ import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
+ projectMenuTriggerSelector,
+ projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
+ workspaceItemSelector,
+ workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
@@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) {
return { rightSection, popoverBody }
}
+
+export async function openProjectMenu(page: Page, projectSlug: string) {
+ const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
+ await expect(trigger).toHaveCount(1)
+
+ await trigger.focus()
+ await page.keyboard.press("Enter")
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ const opened = await menu
+ .waitFor({ state: "visible", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) {
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+ }
+
+ await trigger.click({ force: true })
+
+ await expect(menu).toBeVisible()
+
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+}
+
+export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
+ const current = await page
+ .getByRole("button", { name: "New workspace" })
+ .first()
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (current === enabled) return
+
+ await openProjectMenu(page, projectSlug)
+
+ const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+ await expect(toggle).toBeVisible()
+ await toggle.click({ force: true })
+
+ const expected = enabled ? "New workspace" : "New session"
+ await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+}
+
+export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ await expect(item).toBeVisible()
+ await item.hover()
+
+ const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
+ await expect(trigger).toBeVisible()
+ await trigger.click({ force: true })
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ await expect(menu).toBeVisible()
+ return menu
+}
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
new file mode 100644
index 000000000..80cd63aa2
--- /dev/null
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -0,0 +1,391 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import fs from "node:fs/promises"
+import path from "node:path"
+import type { Page } from "@playwright/test"
+
+import { test, expect } from "../fixtures"
+
+test.describe.configure({ mode: "serial" })
+import {
+ cleanupTestProject,
+ clickMenuItem,
+ confirmDialog,
+ createTestProject,
+ openSidebar,
+ openWorkspaceMenu,
+ seedProjects,
+ setWorkspacesEnabled,
+} from "../actions"
+import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+function slugFromUrl(url: string) {
+ return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
+ const project = await createTestProject()
+ const rootSlug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(rootSlug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+
+ await openSidebar(page)
+
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+
+ return { project, rootSlug, slug, directory: dir }
+}
+
+test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const slug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(slug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+ await openSidebar(page)
+
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+
+ await setWorkspacesEnabled(page, slug, true)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+
+ await setWorkspacesEnabled(page, slug, false)
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can create a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const slug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(slug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, slug, true)
+
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+
+ await expect
+ .poll(
+ () => {
+ const currentSlug = slugFromUrl(page.url())
+ return currentSlug.length > 0 && currentSlug !== slug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const workspaceSlug = slugFromUrl(page.url())
+ const workspaceDir = base64Decode(workspaceSlug)
+
+ await openSidebar(page)
+
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+
+ await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+
+ await cleanupTestProject(workspaceDir)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can rename a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const rename = `e2e workspace ${Date.now()}`
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+ await expect(menu).toHaveCount(0)
+
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ await expect(item).toBeVisible()
+ const input = item.locator(inlineInputSelector).first()
+ await expect(input).toBeVisible()
+ await input.fill(rename)
+ await input.press("Enter")
+ await expect(item).toContainText(rename)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const readme = path.join(createdDir, "README.md")
+ const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+ const original = await fs.readFile(readme, "utf8")
+ const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+ await fs.writeFile(readme, dirty, "utf8")
+ await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(true)
+
+ await expect
+ .poll(async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ })
+ .toBeGreaterThan(0)
+
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Reset$/i, { force: true })
+ await confirmDialog(page, /^Reset workspace$/i)
+
+ await expect
+ .poll(
+ async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(0)
+
+ await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(false)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can delete a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Delete$/i, { force: true })
+ await confirmDialog(page, /^Delete workspace$/i)
+
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const rootSlug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ const workspaces = [] as { directory: string; slug: string }[]
+
+ const listSlugs = async () => {
+ const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+ const slugs = await nodes.evaluateAll((els) => {
+ return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+ })
+ return slugs
+ }
+
+ const waitReady = async (slug: string) => {
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+ }
+
+ const drag = async (from: string, to: string) => {
+ const src = page.locator(workspaceItemSelector(from)).first()
+ const dst = page.locator(workspaceItemSelector(to)).first()
+
+ await src.scrollIntoViewIfNeeded()
+ await dst.scrollIntoViewIfNeeded()
+
+ const a = await src.boundingBox()
+ const b = await dst.boundingBox()
+ if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+
+ await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+ await page.mouse.up()
+ }
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(rootSlug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ for (const _ of [0, 1]) {
+ const prev = slugFromUrl(page.url())
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug && slug !== prev
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+ workspaces.push({ slug, directory: dir })
+
+ await openSidebar(page)
+ }
+
+ if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+ const a = workspaces[0].slug
+ const b = workspaces[1].slug
+
+ await waitReady(a)
+ await waitReady(b)
+
+ const list = async () => {
+ const slugs = await listSlugs()
+ return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+ }
+
+ await expect
+ .poll(async () => {
+ const slugs = await list()
+ return slugs.length === 2
+ })
+ .toBe(true)
+
+ const before = await list()
+ const from = before[1]
+ const to = before[0]
+ if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+ await drag(from, to)
+
+ await expect.poll(async () => await list()).toEqual([from, to])
+ } finally {
+ await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
+ await cleanupTestProject(project)
+ }
+})
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 90cfef8db..317c70969 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
+export const projectWorkspacesToggleSelector = (slug: string) =>
+ `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
@@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
+export const workspaceItemSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
+
+export const workspaceMenuTriggerSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
+
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index 10819e69f..57bf86b5a 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
+const win = process.platform === "win32"
export default defineConfig({
testDir: "./e2e",
@@ -14,7 +15,8 @@ export default defineConfig({
expect: {
timeout: 10_000,
},
- fullyParallel: true,
+ fullyParallel: !win,
+ workers: win ? 1 : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index f049dc3bc..845a4fc83 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) {
>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
- <div class="group/workspace relative">
+ <div
+ class="group/workspace relative"
+ data-component="workspace-item"
+ data-workspace={base64Encode(props.directory)}
+ >
<div class="flex items-center gap-1">
<Show
when={workspaceEditActive()}
fallback={
- <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
+ <Collapsible.Trigger
+ class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
+ data-action="workspace-toggle"
+ data-workspace={base64Encode(props.directory)}
+ >
{header()}
</Collapsible.Trigger>
}
@@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md"
+ data-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
@@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
+ data-action="project-workspaces-toggle"
+ data-project={base64Encode(p.worktree)}
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
onSelect={() => {
const enabled = layout.sidebar.workspaces(p.worktree)()
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 87cf3a082..556fad01f 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -332,7 +332,9 @@ export namespace Session {
export async function* list() {
const project = Instance.project
for (const item of await Storage.list(["session", project.id])) {
- yield Storage.read<Info>(item)
+ const session = await Storage.read<Info>(item).catch(() => undefined)
+ if (!session) continue
+ yield session
}
}
@@ -340,7 +342,8 @@ export namespace Session {
const project = Instance.project
const result = [] as Session.Info[]
for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read<Info>(item)
+ const session = await Storage.read<Info>(item).catch(() => undefined)
+ if (!session) continue
if (session.parentID !== parentID) continue
result.push(session)
}
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 0f2e2f4a0..b0dfd57dd 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -219,6 +219,13 @@ export namespace Worktree {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
}
+ async function canonical(input: string) {
+ const abs = path.resolve(input)
+ const real = await fs.realpath(abs).catch(() => abs)
+ const normalized = path.normalize(real)
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized
+ }
+
async function candidate(root: string, base?: string) {
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
@@ -374,7 +381,7 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
+ const directory = await canonical(input.directory)
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -397,7 +404,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
}
@@ -423,8 +436,9 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
- if (directory === path.resolve(Instance.worktree)) {
+ const directory = await canonical(input.directory)
+ const primary = await canonical(Instance.worktree)
+ if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
@@ -450,7 +464,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}