diff --git a/CHANGELOG.md b/CHANGELOG.md index 917c9f826e..0979de5e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **A new sandbox's project no longer flashes "workspace"** — a sandbox's + project folder is mounted inside the guest at a fixed path, and until the + app finished renaming the project after your folder — a round trip through + the sandbox's server — the sidebar showed that guest path's name, + "workspace", instead. The app already knows which folder each guest path + came from, so an unnamed sandbox project now shows the real folder name from + the first moment; the rename still happens underneath and any name you set + yourself still wins. - **Deleting a sandbox now asks for confirmation and spells out what is lost** — Delete in a sandbox's menu acted immediately, and it is nothing like removing an ordinary server entry: it erases the sandbox's disk, taking diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d33edb966b..7002bb8b8d 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -268,6 +268,7 @@ from the squash message first, or use a plain merge. | `packages/opencode/src/command/template/initialize.txt` | "OpenCode" → "CoDev Code" (×2) | `/init` writes this guidance into the user's `AGENTS.md`. Edited in place rather than wrapped because the file is already fork-divergent (config-file rename row) and barely churns. | Keep our two strings; take upstream's surrounding text and rebrand any new "OpenCode" mention. | | `packages/core/package.json` | Added dependency `"@codev/brand": "workspace:*"` | The built-in skill registration rebrands its body through the fork-only `packages/codev-brand`. | Keep our dependency line; take upstream's other fields. | | `turbo.json` | Added task `"@codev/sandbox#test"` (no deps, no outputs) | CI's `bun turbo test` only runs test tasks registered here; the fork-only sandbox package's tests must gate PRs. | Keep our task entry; take upstream's other tasks. | +| Sandbox project label fallback: `packages/app/src/context/global.tsx` | `enrich()` (the per-server project-list decorator) gains one fork line: when the merged project record has no `name` yet, it fills it from the fork-only `sandboxProjectHostName(conn, worktree, primary)` (`packages/app/src/sandbox/projects.ts`), which maps a sandbox connection's guest paths back to host folder basenames — `conn.projectDir` for the primary (matched by the synced primary directory, or the `/workspace` literal before sync loads) and `conn.mounts[].path` for extras. Non-sandbox connections and named projects are untouched (the helper returns `undefined` / the branch is skipped). | A fresh sandbox project renders `displayName()` = name-or-worktree-basename, and inside the guest the primary worktree is `/workspace`, so every new sandbox flashed the label "workspace" until `adoptFolderName`'s server-side rename round-tripped (guest reports the project → `project.update` → `project.updated` event). The fallback is display-only; the durable rename through the server is unchanged and takes precedence the moment it lands. | Keep the one `if (!base.name)` line after upstream's `base` construction and take upstream's surrounding `enrich` changes. If upstream renames or restructures `enrich`, re-apply the fallback wherever project records are decorated for the sidebar list. The helper stays in the fork-only sandbox module — do not inline it. | | Sandbox/WSL delete cleanup + confirmation: `packages/app/src/context/server.tsx`, `packages/app/src/components/dialog-select-server.tsx`, `packages/app/src/i18n/{en,es}.ts` | `server.tsx` gains `forget(key)` beside `remove(key)`: it deletes the persisted per-server `projects`/`lastProject`/`recentlyClosed` map entries for one key. `dialog-select-server.tsx` `handleRemove` (already diverged, see the renderer-slice row) additionally: for `sandbox:` and `wsl:` keys, awaits a confirmation through the fork-only `packages/app/src/sandbox/dialog-delete.tsx` / `packages/app/src/wsl/dialog-delete.tsx` (lazy-imported, pushed with `dialog.push` so it stacks on whatever dialog opened the menu) and returns without touching anything when declined; and calls `server.forget(key)` right after `server.remove(key)` for both key kinds. The controller gains a `useDialog()` binding for this. i18n gains the `sandbox.delete.*` and `wsl.delete.*` families. The two dialogs state different consequences on purpose: deleting a sandbox erases its disk (sessions, terminals, guest files — permanently; mounted host folders survive), while removing a WSL server destroys nothing — the app never unregisters distributions — it only forgets the app-side project state. | `remove()` only edits the stored HTTP server list; the project maps are keyed by server key and outlive the server, which is right for an HTTP server that still exists remotely but wrong for a deleted sandbox or removed distro — a recreated sandbox with the same name inherited the old projects (`server.projects["sandbox:"]`) and, via the leftover `recentlyClosed`, silently refused to seed folders closed in the previous sandbox's life; a re-added distro likewise inherited its previous project list. And deleting a sandbox erases guest data permanently, so it must not be a one-click menu action; the confirmations live in `handleRemove` because the settings rows and the generic server list all funnel through it. | Keep the confirm-and-forget block at the top of `handleRemove` and the `forget` calls between `server.remove(key)` and the awaited platform removals; take upstream's surrounding changes. `forget` is fork-only API — if upstream reworks the persisted `server` record shape, re-point the three deletes at the new maps. Do not gate `forget` behind the platform removal succeeding: the tab/workspace cleanup ahead of it already treats the server as gone. HTTP removals deliberately keep their maps — the server still exists remotely and re-adding it should restore its projects. If the app ever gains real distro unregistration, the WSL dialog's "nothing is deleted" wording must change with it. | ## Known rebrand gaps diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index d7861bd78e..5e2448b71e 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -6,7 +6,7 @@ import { pathKey } from "@/utils/path-key" import { useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" -import { seedSandboxProjects } from "@/sandbox/projects" +import { sandboxProjectHostName, seedSandboxProjects } from "@/sandbox/projects" import { getOwner } from "solid-js/web" import { QueryClient } from "@tanstack/solid-query" import type { ServerScope } from "@/utils/server-scope" @@ -124,6 +124,11 @@ function createServerCtx( // Without this, different subdirectories of the same git repo would share the same // icon from the database instead of using their individual overrides. const base = { ...metadata, ...project } + // Fork: an unnamed sandbox project would render its guest path's basename + // ("workspace") until the seeding rename lands; show the host folder's + // name the connection already carries instead. + if (!base.name) + base.name = sandboxProjectHostName(conn, project.worktree, sync.data.path.directory || sync.data.path.worktree) if (childStore.icon) { return { ...base, icon: { ...base.icon, override: childStore.icon } } } diff --git a/packages/app/src/sandbox/projects.test.ts b/packages/app/src/sandbox/projects.test.ts new file mode 100644 index 0000000000..17d306d7af --- /dev/null +++ b/packages/app/src/sandbox/projects.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import type { ServerConnection } from "@/context/server" +import { sandboxProjectHostName } from "./projects" + +const sandbox = (input?: Partial<{ projectDir: string; mounts: { path: string; guestPath: string }[] }>) => + ({ + type: "sidecar", + variant: "sandbox", + name: "codev-work", + projectDir: "/home/user/repos/openwork", + http: { url: "http://127.0.0.1:4096" }, + ...input, + }) satisfies ServerConnection.Any + +test("names the primary folder after the host project dir", () => { + const conn = sandbox() + expect(sandboxProjectHostName(conn, "/workspace", "/workspace")).toBe("openwork") + // WSL reports the translated host path as the primary directory. + expect(sandboxProjectHostName(conn, "/mnt/c/repos/openwork", "/mnt/c/repos/openwork")).toBe("openwork") +}) + +test("covers /workspace before sync has reported the primary directory", () => { + expect(sandboxProjectHostName(sandbox(), "/workspace", undefined)).toBe("openwork") + expect(sandboxProjectHostName(sandbox(), "/workspace", "")).toBe("openwork") +}) + +test("names extra mounts after their host folder", () => { + const conn = sandbox({ + mounts: [{ path: "/home/user/works/vibe-coding", guestPath: "/mnt/vibe-coding" }], + }) + expect(sandboxProjectHostName(conn, "/mnt/vibe-coding", "/workspace")).toBe("vibe-coding") +}) + +test("returns nothing for unknown guest paths and non-sandbox servers", () => { + expect(sandboxProjectHostName(sandbox(), "/mnt/unknown", "/workspace")).toBeUndefined() + const http: ServerConnection.Any = { type: "http", http: { url: "http://example.com" } } + expect(sandboxProjectHostName(http, "/workspace", "/workspace")).toBeUndefined() +}) diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts index 983ac8544e..34ad809d56 100644 --- a/packages/app/src/sandbox/projects.ts +++ b/packages/app/src/sandbox/projects.ts @@ -26,6 +26,28 @@ type SandboxServerCtx = { } } +// The guest only ever sees its mount points, so until adoptFolderName's +// rename (below) lands, an unnamed sandbox project's label falls back to the +// guest path's basename — "workspace" for every primary folder. The app +// already knows which host folder each guest path came from; label with its +// name in the meantime. Display-only: the durable rename still goes through +// the server. +export function sandboxProjectHostName( + conn: ServerConnection.Any, + worktree: string, + primaryDirectory: string | undefined, +) { + if (conn.type !== "sidecar" || conn.variant !== "sandbox") return undefined + // bwrap/vz guests mount the project folder at /workspace; WSL mounts it at + // the translated host path, whose basename already matches the host folder. + // The synced primary covers any backend; the /workspace literal covers the + // moment before sync has loaded on a relaunch. + const primary = worktree === "/workspace" || (!!primaryDirectory && worktree === primaryDirectory) + if (primary) return getFilename(conn.projectDir ?? "") || undefined + const mount = conn.mounts?.find((entry) => entry.guestPath === worktree) + return mount ? getFilename(mount.path) || undefined : undefined +} + // A sandbox exposes the project folder it was created with — mounted at a // fixed guest path: /workspace on macOS and Linux, the translated Windows // path on WSL — plus any extra folders mounted later, each at its own guest