0}>
@@ -780,11 +802,19 @@ function HomeProjectRow(props: {
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
+ // Fork: resolves how this row's destructive action should read. Undefined is
+ // an ordinary project, which keeps Close. An object means the directory is a
+ // folder mounted into a sandbox, so the action is Unmount; `unmount` is
+ // absent when the folder cannot be unmounted — the one the sandbox was
+ // created with is fixed for its lifetime — and the item is shown disabled
+ // rather than silently reading differently from the rows beside it.
+ unmountProject?: (server: ServerConnection.Any, directory: string) => { unmount?: () => void } | undefined
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType
}) {
const global = useGlobal()
const platform = usePlatform()
+ const unmount = () => props.unmountProject?.(props.server, props.project.worktree)
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
const [state, setState] = createStore({ menuOpen: false })
const canRevealInFileManager = () =>
@@ -853,9 +883,28 @@ function HomeProjectRow(props: {
{props.language.t("sidebar.project.clearNotifications")}
- props.closeProject(props.server, props.project.worktree)}>
- {props.language.t("common.close")}
-
+ {/* Fork: unmounting removes the folder from the sandbox rather
+ than just hiding its row, so it takes the same danger colour
+ as Delete — set through the item's own `--menu-v2-fg`, which
+ is what reaches the label span menu-v2.css colours. */}
+ props.closeProject(props.server, props.project.worktree)}>
+ {props.language.t("common.close")}
+
+ }
+ >
+ {(state) => (
+ state().unmount?.()}
+ >
+ {props.language.t("sidebar.project.unmount")}
+
+ )}
+
diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts
index 41c5d26ed0..983ac8544e 100644
--- a/packages/app/src/sandbox/projects.ts
+++ b/packages/app/src/sandbox/projects.ts
@@ -45,6 +45,13 @@ export function seedSandboxProjects(
// A project is named once per app run. The rename lands back as a
// project.updated event, but the effect can re-run before it arrives.
const named = new Set()
+ // Folders this run has already opened. The `closed` check below cannot carry
+ // that on its own: the ctx's recentlyClosed is filtered down to directories
+ // the guest server reports as projects, and a folder that has never held a
+ // session has no such record — so closing one reads as "never opened" on the
+ // very next run of this effect, and it would be opened straight back up.
+ // Seeding a folder at most once is what lets the user close it.
+ const seeded = new Set()
createEffect(() => {
for (const conn of connections()) {
@@ -56,14 +63,24 @@ export function seedSandboxProjects(
{ directory: primary, hostPath: conn.projectDir },
...(conn.mounts ?? []).map((mount) => ({ directory: mount.guestPath, hostPath: mount.path })),
]
+ // Unmounting a folder forgets that it was seeded, so mounting it again
+ // seeds it again rather than leaving the user with no project for it.
+ const live = new Set(mounted.map((entry) => `${conn.name}\n${entry.directory}`))
+ for (const key of seeded) {
+ if (key.startsWith(`${conn.name}\n`) && !live.has(key)) seeded.delete(key)
+ }
for (const entry of mounted) {
+ const key = `${conn.name}\n${entry.directory}`
const open = ctx.projects.list().some((project) => project.worktree === entry.directory)
// Reopening a project the user closed on purpose would make it
// impossible to remove. A mount removed from the sandbox keeps its
// project open until the user closes it — sessions may reference it.
const closed = ctx.projects.recentlyClosed().some((project) => project.worktree === entry.directory)
- if (!open && !closed) ctx.projects.open(entry.directory)
- void adoptFolderName(ctx, entry.hostPath, entry.directory, named, `${conn.name}\n${entry.directory}`)
+ if (!open && !closed && !seeded.has(key)) {
+ seeded.add(key)
+ ctx.projects.open(entry.directory)
+ }
+ void adoptFolderName(ctx, entry.hostPath, entry.directory, named, key)
}
}
})
diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx
index b17e6d1eb9..46e02dcb0b 100644
--- a/packages/app/src/sandbox/settings.tsx
+++ b/packages/app/src/sandbox/settings.tsx
@@ -142,7 +142,14 @@ export function SandboxServerSettings(props: {
- request.mutate(() => props.controller.handleRemove(key))}>
+ {/* Same danger hook the session menu uses: menu-v2.css
+ colours the label span, so a text utility on the item
+ would not reach it. */}
+ request.mutate(() => props.controller.handleRemove(key))}
+ >
{language.t("dialog.server.menu.delete")}
diff --git a/packages/app/test-browser/sandbox-projects.test.ts b/packages/app/test-browser/sandbox-projects.test.ts
index 60c721bfd9..2138c9b854 100644
--- a/packages/app/test-browser/sandbox-projects.test.ts
+++ b/packages/app/test-browser/sandbox-projects.test.ts
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
-import { ServerConnection } from "@/context/server"
+import { createStore } from "solid-js/store"
+import { createServerProjects, ServerConnection } from "@/context/server"
+import { pathKey } from "@/utils/path-key"
+import { ServerScope } from "@/utils/server-scope"
import { seedSandboxProjects } from "@/sandbox/projects"
function sandboxConnection(name: string, projectDir?: string): ServerConnection.Any {
@@ -194,3 +197,72 @@ test("seeds a sandbox that becomes ready later", () => {
expect(sandbox.opened).toEqual(["/workspace"])
dispose()
})
+
+// The fakes above hand seedSandboxProjects a friendly ctx. These two drive the
+// real project store and the real recentlyClosed shape instead, because that is
+// where closing a mounted folder actually broke: the row came back on its own,
+// and once the unmount that followed it landed, the menu had fallen back to
+// Close. Two independent causes, so two tests.
+function realStore(serverProjects: string[]) {
+ const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
+ const projects = createServerProjects({ scope: () => ServerScope.local, store, setStore })
+ // context/global.tsx enriches recentlyClosed by filtering it down to the
+ // directories the guest server reports as projects. A mounted folder that has
+ // never held a session has no such record.
+ const known = new Set(serverProjects.map((worktree) => pathKey(worktree)))
+ const ctx = {
+ sdk: { client: { project: { update: async () => {} } } },
+ projects: {
+ ...projects,
+ recentlyClosed: () =>
+ projects
+ .recentlyClosed()
+ .filter((worktree) => known.has(pathKey(worktree)))
+ .map((worktree) => ({ worktree })),
+ },
+ sync: {
+ data: {
+ path: { directory: "/workspace", worktree: "/workspace" },
+ project: serverProjects.map((worktree, index) => ({ id: `p${index}`, worktree })),
+ },
+ },
+ }
+ const conn = {
+ displayName: "work",
+ type: "sidecar",
+ variant: "sandbox",
+ name: "work",
+ projectDir: "/home/dev/proj",
+ mounts: [{ path: "/home/dev/shared", guestPath: "/mnt/shared" }],
+ http: { url: "http://127.0.0.1:1234" },
+ } as ServerConnection.Any
+ const dispose = createRoot((d) => {
+ seedSandboxProjects(
+ () => [conn],
+ () => ctx as never,
+ )
+ return d
+ })
+ return { projects, dispose, open: () => projects.list().map((project) => project.worktree) }
+}
+
+// close() writes the open list and the recently-closed list separately, and
+// Solid flushes effects between them: without a batch this effect saw a project
+// that was in neither list and seeded it again.
+test("closing a mounted folder sticks when the guest knows the project", () => {
+ const harness = realStore(["/workspace", "/mnt/shared"])
+ expect(harness.open()).toContain("/mnt/shared")
+ harness.projects.close("/mnt/shared")
+ expect(harness.open()).not.toContain("/mnt/shared")
+ harness.dispose()
+})
+
+// The harder half: no server-side project record means the folder never appears
+// in recentlyClosed at all, so `closed` can never carry the user's decision.
+test("closing a mounted folder sticks when the guest has no project for it", () => {
+ const harness = realStore(["/workspace"])
+ expect(harness.open()).toContain("/mnt/shared")
+ harness.projects.close("/mnt/shared")
+ expect(harness.open()).not.toContain("/mnt/shared")
+ harness.dispose()
+})