fix(files): a workspace whose folder was deleted leaves the Files places
The lane payload carries folder_missing (ENOENT/ENOTDIR only). Files hides such a workspace and never reopens it as the default place; the Workspace sidebar strikes its name through with a hint to remove it from its menu. A folder that cannot be read no longer keeps the previous folder's entries under its error, and a change in Files re-reads the workspace list.
This commit is contained in:
@@ -369,16 +369,21 @@ export function FileBrowser({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, initialHome, session, source, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read after every change here too: deleting a workspace's folder takes
|
||||
// it out of the places (a workspace whose folder is gone has nothing to show).
|
||||
const loadLanes = useCallback(() => {
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadLanes();
|
||||
api.files
|
||||
.homes()
|
||||
.then((r) => setHomes(r.homes))
|
||||
.catch(() => setHomes([]));
|
||||
}, []);
|
||||
}, [loadLanes]);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
@@ -439,6 +444,8 @@ export function FileBrowser({
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// Never leave the last folder's entries under another folder's error.
|
||||
if (view === "files") setListing(null);
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
setError(
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
@@ -455,6 +462,7 @@ export function FileBrowser({
|
||||
|
||||
const changed = () => {
|
||||
void load();
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
@@ -932,11 +940,18 @@ export function FileBrowser({
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
// A workspace whose folder was deleted has nothing to browse — it stays a
|
||||
// place only while it is the one open (so its error has a row to sit on).
|
||||
const placeLanes = useMemo(
|
||||
() =>
|
||||
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
|
||||
[lanes, target]
|
||||
);
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of lanes) {
|
||||
for (const l of placeLanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
@@ -959,9 +974,9 @@ export function FileBrowser({
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, lanes, myId]);
|
||||
}, [homes, placeLanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
@@ -1687,9 +1702,7 @@ export function FileBrowser({
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>
|
||||
{t("fx.download")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
|
||||
@@ -161,12 +161,19 @@ export function WorkspaceRow({
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
<span
|
||||
data-folder-missing={lane.folder_missing ? "true" : undefined}
|
||||
className={`truncate text-[13px] font-medium ${
|
||||
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
|
||||
@@ -459,6 +459,33 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a workspace whose folder was deleted out of the places", async () => {
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
|
||||
await screen.findByText("one", { selector: "aside span" });
|
||||
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the last folder's entries when the next one cannot be read", async () => {
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
|
||||
if (p === "src") throw new Error("ENOENT: no such file or directory");
|
||||
return root;
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText(/ENOENT/);
|
||||
expect(screen.queryByText("a.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
|
||||
@@ -343,6 +343,7 @@
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file",
|
||||
"row.actions": "Workspace actions",
|
||||
"row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions",
|
||||
"row.folderMissing": "Folder deleted — remove this workspace from its ⋯ menu",
|
||||
"menu.rename": "Rename",
|
||||
"menu.newHere": "New workspace here…",
|
||||
"menu.copyPath": "Copy path",
|
||||
|
||||
@@ -343,6 +343,7 @@
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file",
|
||||
"row.actions": "Thao tác workspace",
|
||||
"row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác",
|
||||
"row.folderMissing": "Thư mục đã bị xóa — gỡ workspace này trong menu ⋯",
|
||||
"menu.rename": "Đổi tên",
|
||||
"menu.newHere": "Workspace mới tại đây…",
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
|
||||
@@ -2731,6 +2731,8 @@ export interface Lane {
|
||||
stage_seconds: number | null;
|
||||
last_event_seconds: number | null;
|
||||
liveness: "active" | "idle" | "dead";
|
||||
/** The workspace's folder no longer exists (deleted from Files or a shell). */
|
||||
folder_missing?: boolean;
|
||||
/** Stage id the server's heuristic infers from ingested tool events, or null
|
||||
* if no signal was seen. Independent of `stage` (the agent's own declaration). */
|
||||
detected_stage: string | null;
|
||||
|
||||
@@ -50,7 +50,10 @@ export function FilesPage() {
|
||||
// The workspace last shown first: Files reached from a workspace opens
|
||||
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
|
||||
// but for hidden files); the home is the fallback.
|
||||
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
||||
// A workspace whose folder was deleted has nothing to open.
|
||||
const lanes = (await api.lanes.list().catch(() => ({ lanes: [] }))).lanes.filter(
|
||||
(l) => !l.folder_missing
|
||||
);
|
||||
const last = lastWorkspace();
|
||||
const shown = lanes.find((l) => l.id === last);
|
||||
if (shown) return setStart({ lane: shown.id });
|
||||
|
||||
@@ -1629,6 +1629,8 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
|
||||
|
||||
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a workspace's record. It deletes that record only — never the worktree, the branch or the database.
|
||||
|
||||
**A workspace whose folder was deleted** (from Files, or a shell) is not removed for you — its record still holds its sessions' history. Every workspace payload carries `folder_missing` (true only when its `cwd` is gone — `ENOENT`/`ENOTDIR`, never a folder the server merely cannot read): the Files page leaves such a workspace out of its places, and the Workspace sidebar shows its name struck through with a hint to remove it from its ⋯ menu.
|
||||
|
||||
## Proof gallery
|
||||
|
||||
A workspace's proof gallery is a collection of QC screenshots captured during active work, grouped by feature (the same slug from **Per-feature state and archive** above) and phase (e.g. desktop, mobile, e2e, visual). A QC agent running an MCP server like `playwright-mcp` captures screenshots to disk at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and generates an optional ticket report at `<slug>/ticket/REPORT.html`. The Workspace gallery panel displays these files grouped by feature, using the same feature picker as the per-feature state section.
|
||||
|
||||
@@ -337,6 +337,16 @@ describe("lanes lib", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("payload says folder_missing only when the folder is gone", () => {
|
||||
const fs = require("node:fs");
|
||||
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-gone-"));
|
||||
const l = lanes.createLane({ cwd: dir });
|
||||
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, false);
|
||||
fs.rmSync(dir, { recursive: true });
|
||||
assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, true);
|
||||
lanes.deleteLane(l.id);
|
||||
});
|
||||
|
||||
it("payload carries node states and progress", () => {
|
||||
const l = lanes.createLane({ cwd: "/tmp/wt/d" });
|
||||
lanes.setStage(l.id, { stage: "review" });
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { db } = require("../db");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -749,6 +750,17 @@ function ownerNames(id) {
|
||||
};
|
||||
}
|
||||
|
||||
/** True only when the folder is GONE (ENOENT/ENOTDIR) — a folder this
|
||||
* process may not read is still there, so it never counts as deleted. */
|
||||
function folderMissing(cwd) {
|
||||
try {
|
||||
fs.statSync(cwd);
|
||||
return false;
|
||||
} catch (err) {
|
||||
return err.code === "ENOENT" || err.code === "ENOTDIR";
|
||||
}
|
||||
}
|
||||
|
||||
function lanePayload(lane, ageSec = null) {
|
||||
const pipeline = getPipeline(lane.pipeline);
|
||||
const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN;
|
||||
@@ -775,6 +787,9 @@ function lanePayload(lane, ageSec = null) {
|
||||
: Math.max(0, Math.round((Date.now() - since) / 1000)),
|
||||
last_event_seconds: ageSec,
|
||||
liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC),
|
||||
// The folder was deleted under the workspace (from Files, a shell): the
|
||||
// record stays — its sessions are history — but nothing can open in it.
|
||||
folder_missing: folderMissing(lane.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user