Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/app/src/context/file/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] === "/")
)
}

12 changes: 7 additions & 5 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -450,6 +450,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return base
}



const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync().data.project) {
Expand Down Expand Up @@ -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)
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 17 additions & 14 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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)
}
})
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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))) {
Expand All @@ -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 })
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
})
})
12 changes: 9 additions & 3 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -104,10 +105,15 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: Session,
projects: T[],
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
byID?: Map<string, T>,
) {
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) =>
Expand Down
Loading