diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index aa047fb287a..88d71f94cfd 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -1,3 +1,4 @@ +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" @@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) { return next } +export async function resolveSlug(slug: string) { + const directory = base64Decode(slug) + if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) + const resolved = await resolveDirectory(directory) + return { directory: resolved, slug: base64Encode(resolved), raw: slug } +} + +export async function waitDir(page: Page, directory: string) { + const target = await resolveDirectory(directory) + await expect + .poll( + async () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + return resolveSlug(slug) + .then((item) => item.directory) + .catch(() => "") + }, + { timeout: 45_000 }, + ) + .toBe(target) + return { directory: target, slug: base64Encode(target) } +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f59278..1416aec7267 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" @@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(btn).toBeVisible() await btn.click({ force: true }) - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + await waitDir(page, space) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(rootButton).toBeVisible() await rootButton.click() + await waitDir(page, space) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 18fa46d3299..0858f26273c 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,18 +1,25 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -async function waitWorkspaceReady(page: Page, slug: string) { +function item(space: { slug: string; raw: string }) { + return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` +} + +function button(space: { slug: string; raw: string }) { + return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}` +} + +async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const row = page.locator(item(space)).first() try { - await item.hover({ timeout: 500 }) + await row.hover({ timeout: 500 }) return true } catch { return false @@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await waitDir(page, next.directory) + return next } -async function openWorkspaceNewSession(page: Page, slug: string) { - await waitWorkspaceReady(page, slug) +async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { + await waitWorkspaceReady(page, space) - const item = page.locator(workspaceItemSelector(slug)).first() - await item.hover() + const row = page.locator(item(space)).first() + await row.hover() - const button = page.locator(workspaceNewSessionSelector(slug)).first() - await expect(button).toBeVisible() - await button.click({ force: true }) + const next = page.locator(button(space)).first() + await expect(next).toBeVisible() + await next.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) - return next + return waitDir(page, space.directory) } -async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - const next = await openWorkspaceNewSession(page, slug) +async function createSessionFromWorkspace( + page: Page, + space: { slug: string; raw: string; directory: string }, + text: string, +) { + const next = await openWorkspaceNewSession(page, space) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(next) + await waitDir(page, next.directory) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) - return { sessionID, slug: next } + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next.slug } } async function sessionDirectory(directory: string, sessionID: string) { @@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a const first = await createWorkspace(page, root, []) trackDirectory(first.directory) - await waitWorkspaceReady(page, first.slug) + await waitWorkspaceReady(page, first) const second = await createWorkspace(page, root, [first.slug]) trackDirectory(second.directory) - await waitWorkspaceReady(page, second.slug) + await waitWorkspaceReady(page, second) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(firstSession.sessionID, first.directory) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aeeccb9bba9..8ee899f18e6 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,4 +1,3 @@ -import { base64Decode } from "@opencode-ai/util/encode" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -13,8 +12,10 @@ import { confirmDialog, openSidebar, openWorkspaceMenu, + resolveSlug, setWorkspacesEnabled, slugFromUrl, + waitDir, waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" @@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug]) - const dir = base64Decode(slug) + const next = await resolveSlug(await waitSlug(page, [rootSlug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { ) .toBe(true) - return { rootSlug, slug, directory: dir } + return { rootSlug, slug: next.slug, directory: next.directory } } test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { @@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - const workspaceSlug = await waitSlug(page, [slug]) - const workspaceDir = base64Decode(workspaceSlug) + const next = await resolveSlug(await waitSlug(page, [slug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => { ) .toBe(true) - await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - await cleanupTestProject(workspaceDir) + await cleanupTestProject(next.directory) }) }) @@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const activeDir = base64Decode(slugFromUrl(page.url())) + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) @@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug, prev]) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await waitDir(page, next.directory) + workspaces.push(next) await openSidebar(page) } diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 933d5e6f96d..2c2e4e886da 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,7 +1,6 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next } async function waitWorkspace(page: Page, slug: string) { @@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page)) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await expect(page.locator(promptSelector)).toBeVisible() return currentDir(page) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 857892123bc..9a282bbb708 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -46,21 +46,13 @@ import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -const Home = lazy(() => import("@/pages/home")) +const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
-const HomeRoute = () => ( - }> - - -) - const SessionRoute = () => ( - }> - - + ) @@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - {props.appChildren} - {props.children} + }> + {props.appChildren} + {props.children} + ) } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f993ffcd890..cd5e079a69a 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,16 +1,15 @@ -import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" -import { createStore } from "solid-js/store" +import { DataProvider } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createMemo, createResource, type ParentProps, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" -import { LocalProvider } from "@/context/local" -import { useGlobalSDK } from "@/context/global-sdk" - -import { DataProvider } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" + function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() @@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() - const navigate = useNavigate() const location = useLocation() const language = useLanguage() const globalSDK = useGlobalSDK() - const directory = createMemo(() => decode64(params.dir) ?? "") - const [state, setState] = createStore({ invalid: "", resolved: "" }) + const navigate = useNavigate() + let invalid = "" + + const [resolved] = createResource( + () => { + if (params.dir) return [location.pathname, params.dir] as const + }, + async ([pathname, b64Dir]) => { + const directory = decode64(b64Dir) - createEffect(() => { - if (!params.dir) return - const raw = directory() - if (!raw) { - if (state.invalid === params.dir) return - setState("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } + if (!directory) { + if (invalid === params.dir) return + invalid = b64Dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } - const current = params.dir - globalSDK - .createClient({ - directory: raw, - throwOnError: true, - }) - .path.get() - .then((x) => { - if (params.dir !== current) return - const next = x.data?.directory ?? raw - batch(() => { - setState("invalid", "") - setState("resolved", next) + return await globalSDK + .createClient({ + directory, + throwOnError: true, }) - if (next === raw) return - const path = location.pathname.slice(current.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - if (params.dir !== current) return - batch(() => { - setState("invalid", "") - setState("resolved", raw) + .path.get() + .then((x) => { + const next = x.data?.directory ?? directory + invalid = "" + if (next === directory) return next + const path = pathname.slice(b64Dir.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) - }) - }) + .catch(() => { + invalid = "" + return directory + }) + }, + ) return ( - + {(resolved) => ( resolved}> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4c3a00dfb5d..cca14fd50c9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return + const key = workspaceKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => p.worktree === directory) + const direct = projects.find((p) => workspaceKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -630,7 +631,10 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const key = workspaceKey(directory) + const project = projects.find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) @@ -1155,13 +1159,16 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { + const key = workspaceKey(directory) const project = layout.projects .list() - .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + .find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => root === directory || dirs.includes(directory), + ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), ) if (known) return known[0] @@ -1177,13 +1184,6 @@ export default function Layout(props: ParentProps) { return currentProject()?.worktree ?? projectRoot(directory) } - function touchProjectRoute() { - const root = currentProject()?.worktree - if (!root) return - if (server.projects.last() !== root) server.projects.touch(root) - return root - } - function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { setStore("lastProjectSession", root, { directory, id, at: Date.now() }) return root @@ -1347,8 +1347,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory + const key = workspaceKey(directory) + const index = list.findIndex((x) => workspaceKey(x.worktree) === key) + const active = workspaceKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1683,38 +1684,55 @@ export default function Layout(props: ParentProps) { const activeRoute = { session: "", sessionProject: "", + directory: "", } createEffect( on( - () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, - ([ready, dir, id]) => { - if (!ready || !dir) { + () => { + const dir = params.dir + const directory = dir ? decode64(dir) : undefined + const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : "" + return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const + }, + ([ready, dir, id, root, directory, resolved]) => { + if (!ready || !dir || !directory) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } - const directory = decode64(dir) - if (!directory) return - - const root = touchProjectRoute() ?? activeProjectRoot(directory) - if (!id) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } + const next = resolved || directory const session = `${dir}/${id}` - if (session !== activeRoute.session) { + + if (!root) { + activeRoute.session = session + activeRoute.directory = next + activeRoute.sessionProject = "" + return + } + + if (server.projects.last() !== root) server.projects.touch(root) + + const changed = session !== activeRoute.session || next !== activeRoute.directory + if (changed) { activeRoute.session = session - activeRoute.sessionProject = syncSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = syncSessionRoute(next, id, root) return } if (root === activeRoute.sessionProject) return - activeRoute.sessionProject = rememberSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = rememberSessionRoute(next, id, root) }, ), ) @@ -1778,8 +1796,13 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? currentDir() : undefined - const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const extra = + directory && + workspaceKey(directory) !== workspaceKey(local) && + !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + ? directory + : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9dbc6c72d2f..1fe52d47a0a 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -104,14 +104,14 @@ describe("layout deep links", () => { describe("layout workspace helpers", () => { test("normalizes trailing slash in workspace key", () => { expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") }) test("preserves posix and drive roots in workspace key", () => { expect(workspaceKey("/")).toBe("/") expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:\\") - expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:\\")).toBe("C:/") + expect(workspaceKey("C://")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/") }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f5742..886ffd26a1a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,11 +1,17 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +type SessionStore = { + session?: Session[] + path: { directory: string } +} + export const workspaceKey = (directory: string) => { - const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) - if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` - if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" - return directory.replace(/[\\/]+$/, "") + const value = directory.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") } function sortSessions(now: number) { @@ -25,13 +31,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) + +export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) -export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => - stores - .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) - .sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record, @@ -40,9 +44,9 @@ export function hasProjectPermissions( return Object.values(request).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[]) => { +export const childMapByParent = (sessions: Session[] | undefined) => { const map = new Map() - for (const session of sessions) { + for (const session of sessions ?? []) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e63..127626febef 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -332,12 +332,13 @@ export const SortableWorkspace = (props: { const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") - const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) + const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.directory) @@ -472,8 +473,9 @@ export const LocalWorkspace = (props: { const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const loading = createMemo(() => !booted() && count() === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree)