Filip пре 2 недеља
родитељ
комит
91f2ac3cb2

+ 70 - 0
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
+}

+ 391 - 0
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)
+  }
+})

+ 9 - 0
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}"]`

+ 3 - 1
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"]],

+ 14 - 2
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)()

+ 5 - 2
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)
     }

+ 25 - 5
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" })
     }