diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 53f072b6cb26..c3c80d91a0a5 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -149,3 +149,14 @@ export function createPathHelpers(scope: () => string) { normalizeDir, } } + +export function isDescendant(parent: string, child: string) { + const windows = /^[A-Za-z]:/.test(parent) || parent.startsWith("\\\\") + const canonParent = (windows ? parent.replace(/\\/g, "/").toLowerCase() : parent.replace(/\\/g, "/")).replace(/\/+$/, "") + const canonChild = (windows ? child.replace(/\\/g, "/").toLowerCase() : child.replace(/\\/g, "/")).replace(/\/+$/, "") + return ( + canonChild.startsWith(canonParent) && + (canonChild === canonParent || canonChild[canonParent.length] === "/") + ) +} + diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index dcac3e778104..d493173cf76f 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -13,7 +13,7 @@ import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" -import { createPathHelpers } from "./file/path" +import { createPathHelpers, isDescendant } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" @@ -450,6 +450,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return base } + + const roots = createMemo(() => { const map = new Map() for (const project of serverSync().data.project) { @@ -654,14 +656,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, - workspaces(directory: string) { - return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + workspaces(directory: string, hasSandboxes?: boolean) { + return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? (hasSandboxes ?? false) }, setWorkspaces(directory: string, value: boolean) { setStore("sidebar", "workspaces", directory, value) }, - toggleWorkspaces(directory: string) { - const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false + toggleWorkspaces(directory: string, hasSandboxes?: boolean) { + const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? (hasSandboxes ?? false) setStore("sidebar", "workspaces", directory, !current) }, }, diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c8a89672dfca..404ad7766ded 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -58,6 +58,7 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" +import { isDescendant } from "@/context/file/path" import { Binary } from "@opencode-ai/core/util/binary" import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" @@ -486,7 +487,7 @@ export function NewHome() { const project = projectForSession(session, projects(), projectByID()) const conn = focusedServer() if (!conn) return - const directory = project?.worktree ?? session.directory + const directory = (project && isDescendant(project.worktree, session.directory)) ? project.worktree : session.directory const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) if (options?.background) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd9d16b90ad6..497a6bd1863b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -581,7 +581,7 @@ export default function LegacyLayout(props: ParentProps) { const project = currentProject() if (!project) return false if (project.vcs !== "git") return false - return layout.sidebar.workspaces(project.worktree)() + return layout.sidebar.workspaces(project.worktree, !!(project.sandboxes && project.sandboxes.length > 0))() }) const visibleSessionDirs = createMemo(() => { @@ -608,7 +608,7 @@ export default function LegacyLayout(props: ParentProps) { (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (!project) continue - if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue + if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree, !!(project.sandboxes && project.sandboxes.length > 0))()) continue setStore("workspaceExpanded", directory, false) } }) @@ -1005,8 +1005,9 @@ export default function LegacyLayout(props: ParentProps) { const project = currentProject() if (!project) return if (project.vcs !== "git") return - const wasEnabled = layout.sidebar.workspaces(project.worktree)() - layout.sidebar.toggleWorkspaces(project.worktree) + const hasSandboxes = !!(project.sandboxes && project.sandboxes.length > 0) + const wasEnabled = layout.sidebar.workspaces(project.worktree, hasSandboxes)() + layout.sidebar.toggleWorkspaces(project.worktree, hasSandboxes) showToast({ title: wasEnabled ? language.t("toast.workspace.disabled.title") @@ -1210,7 +1211,8 @@ export default function LegacyLayout(props: ParentProps) { return true } - const projectSession = store.lastProjectSession[root] + const activeDirs = directory ? [directory] : dirs + const projectSession = directory && pathKey(directory) !== pathKey(root) ? undefined : store.lastProjectSession[root] if (projectSession?.id) { await refreshDirs(projectSession.directory) const opened = await openSession(projectSession) @@ -1219,7 +1221,7 @@ export default function LegacyLayout(props: ParentProps) { } const latest = latestRootSession( - dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]), + activeDirs.map((item) => serverSync().child(item, { bootstrap: false })[0]), Date.now(), ) if (latest && (await openSession(latest))) { @@ -1228,7 +1230,7 @@ export default function LegacyLayout(props: ParentProps) { const fetched = latestRootSession( await Promise.all( - dirs.map(async (item) => ({ + activeDirs.map(async (item) => ({ path: { directory: item }, session: await serverSDK() .client.session.list({ directory: item }) @@ -1242,7 +1244,7 @@ export default function LegacyLayout(props: ParentProps) { return } - navigateWithSidebarReset(`/${base64Encode(root)}/session`) + navigateWithSidebarReset(`/${base64Encode(directory ?? root)}/session`) } function navigateToSession(session: Session | undefined) { @@ -1334,13 +1336,14 @@ export default function LegacyLayout(props: ParentProps) { } function toggleProjectWorkspaces(project: LocalProject) { - const enabled = layout.sidebar.workspaces(project.worktree)() + const hasSandboxes = !!(project.sandboxes && project.sandboxes.length > 0) + const enabled = layout.sidebar.workspaces(project.worktree, hasSandboxes)() if (enabled) { - layout.sidebar.toggleWorkspaces(project.worktree) + layout.sidebar.toggleWorkspaces(project.worktree, hasSandboxes) return } if (project.vcs !== "git") return - layout.sidebar.toggleWorkspaces(project.worktree) + layout.sidebar.toggleWorkspaces(project.worktree, hasSandboxes) } const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => { @@ -1904,7 +1907,7 @@ export default function LegacyLayout(props: ParentProps) { closeProject, showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj), toggleProjectWorkspaces, - workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), + workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree, !!(project.sandboxes && project.sandboxes.length > 0))(), workspaceIds, workspaceLabel, sessionProps: { @@ -1953,12 +1956,12 @@ export default function LegacyLayout(props: ParentProps) { const item = project() if (!item) return false if (item.vcs !== "git") return false - return layout.sidebar.workspaces(item.worktree)() + return layout.sidebar.workspaces(item.worktree, !!(item.sandboxes && item.sandboxes.length > 0))() }) const canToggle = createMemo(() => { const item = project() if (!item) return false - return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)() + return item.vcs === "git" || layout.sidebar.workspaces(item.worktree, !!(item.sandboxes && item.sandboxes.length > 0))() }) const homedir = createMemo(() => serverSync().data.path.home) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index df87ddfecd2a..eb108254a621 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -19,7 +19,9 @@ import { homeSessionServerStatus, latestRootSession, toggleHomeProjectSelection, + projectForSession, } from "./helpers" +import { isDescendant } from "@/context/file/path" import { pathKey } from "@/utils/path-key" import { ServerConnection } from "@/context/server" @@ -318,4 +320,24 @@ describe("layout workspace helpers", () => { expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") expect(errorMessage("unknown", "fallback")).toBe("fallback") }) + + test("isDescendant checks path containment correctly", () => { + expect(isDescendant("/tmp/parent", "/tmp/parent/child")).toBe(true) + expect(isDescendant("/tmp/parent", "/tmp/parent")).toBe(true) + expect(isDescendant("/tmp/parent", "/tmp/parent-other")).toBe(false) + expect(isDescendant("/tmp/parent", "/tmp/other")).toBe(false) + expect(isDescendant("C:\\tmp\\parent", "c:/tmp/parent/child")).toBe(true) + }) + + test("projectForSession resolves to the correct project when multiple share same ID", () => { + const list = [ + { id: "project-1", worktree: "/tmp/production" }, + { id: "project-1", worktree: "/tmp/dev" }, + ] + const sessProd = session({ id: "s1", projectID: "project-1", directory: "/tmp/production/src" }) + const sessDev = session({ id: "s2", projectID: "project-1", directory: "/tmp/dev/src" }) + + expect(projectForSession(sessProd, list)?.worktree).toBe("/tmp/production") + expect(projectForSession(sessDev, list)?.worktree).toBe("/tmp/dev") + }) }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index ce793e282bde..e072f32840e9 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -3,6 +3,7 @@ import { type Session } from "@opencode-ai/sdk/v2/client" import { pathKey } from "@/utils/path-key" import type { ServerConnection } from "@/context/server" import type { HomeProjectSelection } from "@/context/layout" +import { isDescendant } from "@/context/file/path" type SessionStore = { session?: Session[] @@ -104,10 +105,15 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url export function projectForSession( session: Session, projects: T[], - byID: Map = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))), + byID?: Map, ) { - const direct = byID.get(session.projectID) - if (direct) return direct + const matching = projects.filter((p) => p.id === session.projectID) + if (matching.length > 0) { + if (matching.length === 1) return matching[0] + const active = matching.find((p) => isDescendant(p.worktree, session.directory)) + if (active) return active + return matching[0] + } const directory = pathKey(session.directory) return projects.find( (project) =>