Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ file is the human-readable summary of what changed and why.
between **Code** and **Work** goes through the same check. A session that is
merely unreachable for a moment still keeps its place; only one the server
says is gone is passed over.
- **Deleting a server now looks destructive** — **Delete** in a server's menu
sat in the same colour as Edit and Set as default, giving the one
irreversible action in the menu no more weight than the rest. It is now red,
matching Delete in a session's menu, for both ordinary servers and WSL ones.
- **Closing a folder on Work now actually closes it** — closing a mounted
folder from a **Work** project's menu removed it from the list and then put
it straight back, so there was no way to get a folder off the list at all.
Two separate causes, both fixed: the close was recorded in two steps and the
app reacted to the moment in between, and a folder you had never started a
session in was not recognised as one you had closed.
- **Each folder in a sandbox now has its own session tabs** — a sandbox can
mount several folders, but all of their sessions shared one tab strip on the
**Work** side, so working in two folders meant reading past the other one's
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion packages/app/src/components/server/server-row-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,16 @@ export const ServerRowMenu: Component<{
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{/* Fork: danger colour comes from the item's own `--menu-v2-fg`
rather than a text utility — menu-v2.css colours the label span
directly, so a class on the item would not reach it. Setting the
variable recolours both, and the highlighted state only swaps
the background, so it survives hover. */}
<MenuV2.Item
disabled={builtin}
style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}
onSelect={() => props.controller.handleRemove(key)}
>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,22 @@ export function createServerProjects<T extends ServerProjectState>(input: {
},
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
//
// Fork: batched. The open list and the recently-closed list are two store
// keys, and Solid flushes effects after every write, so between the two a
// project is momentarily in neither list. An effect that reconciles
// against both — the sandbox project seeding in src/sandbox/projects.ts —
// sees that gap and opens the project straight back up.
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
batch(() => {
remove(directory)
setStore("recentlyClosed", input.scope(), closed)
})
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
Expand Down
21 changes: 19 additions & 2 deletions packages/app/src/sandbox/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
// 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<string>()

createEffect(() => {
for (const conn of connections()) {
Expand All @@ -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)
}
}
})
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/wsl/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,13 @@ export function WslServerSettings(props: {
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => remove(key)}>
{/* Same danger hook the session and server menus use:
menu-v2.css colours the label span, so a text
utility on the item would not reach it. */}
<MenuV2.Item
style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}
onSelect={() => remove(key)}
>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
Expand Down
74 changes: 73 additions & 1 deletion packages/app/test-browser/sandbox-projects.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
})
Loading