diff --git a/README.md b/README.md index 5096853..278f916 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ hub's own storage, never something a syncing client points at directly: | `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` | | `bdrive status [folder]` | Projects, daemon state, pending changes | | `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file — newest first by the time shown, which is when the file was written (ops recorded before this was tracked, and deletes, show their sync time instead) | -| `bdrive restore [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view | +| `bdrive restore [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view — or **undo this run** in the run card's header to put back every file that run touched at once | | `bdrive export [folder]` | Export the whole project — every device's journal, all blobs, full history — from its hub to a portable `.tar.gz` (`-o` names the file) | | `bdrive import ` | Import an export archive as a new project on the hub you're logged into (always a NEW project; `--name` overrides the archive's); history and authorship carry over. Refuses an archive whose journals reference content it doesn't hold (`--allow-incomplete` overrides). Move projects between hubs — cloud → self-hosted or back — with `export` + `login` + `import` | | `bdrive serve [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub (`bdrive web` is a deprecated alias) | diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index 3d16218..9815f38 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -97,6 +97,7 @@ classDiagram note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; the initial selection is options[0].value — the same array element the RECOMMENDED badge indexes, so the badged row and the checked row are one row by construction (on a template-less hub that row is 'I already have a folder', which still creates an empty project). modal.tsx keeps its one-field API — teaching it about choices would tax every other caller" note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded" note for components "FileView's transformHTML now drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row" + note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the "changed after this run" warning inline" note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree" class lib { diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index e7c7e75..ab7e009 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -65,6 +65,7 @@ classDiagram -loadSourcedOps(ctx) []sourcedOp -cacheJournals(keep, misses, parsed, sizes) -appendOp(ctx, op) + -appendOps(ctx, ops) ONE read-modify-write } class sourcedOp { +Op journal.Op @@ -116,7 +117,16 @@ classDiagram } note for DirectUploader "BlobSize replaced HasBlob: in direct mode the server never sees the bytes, so the CALLER's declared size was the only number it had to quota-check and journal — and the caller picks it. Size now comes from storage, and the commit journals and charges that" note for DirectUploader "Commit's note is "" for an upload and "restore <path>@<sha8>" for POST /api/p/{id}/restore — which is the upload commit minus the upload: find the historical op for (path, sha), journal a NEW put at its blob. Never rewrites a journal." - note for RemoteSource "Every write ends at appendOp: stamp Seq/Lamport/Time + this server's Identity, append ONE op to journal/<own-device>.jsonl. Commit does that for a put; Remove (POST /api/p/{id}/remove, restore's gates + a snapshot existence check) does it for a delete — the only server path that takes a file away, and itself undone by restoring the DELETED row." + note for RemoteSource "Every write ends at appendOps: stamp Seq/Lamport/Time + this server's Identity across the batch, append N ops to journal/<own-device>.jsonl in ONE read-modify-write (appendOp is the single-op call). Commit does that for a put; Remove (POST /api/p/{id}/remove, restore's gates + a snapshot existence check) does it for a delete — the only server path that takes a file away, and itself undone by restoring the DELETED row. The batch is not an optimization: ONE Put of ONE object either lands or it does not, which is the whole atomicity argument for undoRunDoor — a loop of appendOp there would leave half a run reverted with nothing to report it." + + class undoRunDoor { + <> + planUndo(sourced, undoSel) undoPlan + undoSel Device From-journal, Session xor Note + undoPlan Ops, Actions, Skipped, After, Refused + preview plan only, no write, no quota + } + note for undoRunDoor "The run-wide form of restore+remove: for every path the run touched, the op that puts it back — a put at the pre-run blob, or a delete for a file the run created. Selection is by sourcedOp.From (the journal, which /store gates), NEVER op.Device, and the note form additionally requires Session == "" because runs.ts can never file a session-carrying op under a note-keyed card. Append-only: the run's own ops are never touched. Same PermWrite + CheckWrite(org,0) gates as its two siblings; the undo's ops carry a note naming the run, so the undo is itself a run card you can undo." class journalDoor { <> @@ -470,6 +480,8 @@ classDiagram DeviceRegistry ..> DeviceInfo DeviceRegistry *-- devKey : (account, id) RemoteSource ..> sourcedOp : attribution comes from the journal key + undoRunDoor ..> sourcedOp : selects a run by the journal it was read from + undoRunDoor ..> RemoteSource : appendOps — the whole run in one Put RemoteSource *-- cachedJournal : parsed ops, keyed on size+mtime ReadLedger ..> ReadStat ReadLedger ..> SessionRead diff --git a/internal/syncer/http_remote_test.go b/internal/syncer/http_remote_test.go index da9b313..06df426 100644 --- a/internal/syncer/http_remote_test.go +++ b/internal/syncer/http_remote_test.go @@ -2,9 +2,12 @@ package syncer import ( "context" + "io" + "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -113,3 +116,86 @@ func TestReadOnlyServerClientStillPulls(t *testing.T) { t.Fatal("client should still pull from a read-only server") } } + +// Undoing a whole agent run at the hub converges like any other change: the +// hub journals the undo under its OWN device, and every other device +// materializes the pre-run content on its next cycle. The repo's convention +// is that a sync feature without a multi-device test is untested where it +// matters — this is that test for BEA-82. +func TestUndoRunConverges(t *testing.T) { + storage := sharedRemote(t) + ts, p := newHub(t, storage, true) + + viaServer, err := remote.Open(context.Background(), ts.URL+"/p/"+p.ID) + if err != nil { + t.Fatal(err) + } + defer viaServer.Close() + + a := newDevice(t, "deva", viaServer) // the agent's machine + b := newDevice(t, "devb", remote.Prefixed(storage, p.ID)) // a teammate + + // Before the run. + write(t, a.Folder, "notes/plan.md", "the plan, as written by a human") + cycle(t, a) + cycle(t, b) + if read(t, b.Folder, "notes/plan.md") != "the plan, as written by a human" { + t.Fatal("b never got the pre-run content") + } + + // The run: one file rewritten, one created, both stamped with the session + // id the agent hook sets. + time.Sleep(10 * time.Millisecond) + a.SessionID = "run-8f21e4" + write(t, a.Folder, "notes/plan.md", "REWRITTEN BY THE AGENT") + write(t, a.Folder, "notes/scratch.md", "invented by the agent") + cycle(t, a) + a.SessionID = "" + cycle(t, b) + if read(t, b.Folder, "notes/scratch.md") != "invented by the agent" { + t.Fatal("b never saw the run") + } + + // Undo the whole run from the hub. + body := strings.NewReader(`{"session":"run-8f21e4","device":"deva"}`) + resp, err := http.Post(ts.URL+"/api/p/"+p.ID+"/undo-run", "application/json", body) + if err != nil { + t.Fatal(err) + } + out, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("undo-run: %d %s", resp.StatusCode, out) + } + + // The teammate converges on the pre-run state without doing anything but + // syncing, and so does the device that made the mess. + time.Sleep(10 * time.Millisecond) + for _, d := range []*Session{b, a} { + cycle(t, d) + if got := read(t, d.Folder, "notes/plan.md"); got != "the plan, as written by a human" { + t.Fatalf("%s has %q after the undo, want the pre-run content", d.Device.ID, got) + } + if _, err := os.Stat(filepath.Join(d.Folder, "notes", "scratch.md")); !os.IsNotExist(err) { + t.Fatalf("%s still has the file the run created", d.Device.ID) + } + } + + // The undo is append-only: the agent's own journal still holds every op + // it ever wrote, and the undo lives in the hub's. + rc, err := storage.Get(context.Background(), p.ID+"/journal/deva.jsonl") + if err != nil { + t.Fatal(err) + } + devaJournal, _ := io.ReadAll(rc) + rc.Close() + // The run's ops are still there, session id and all: an undo appends, it + // never rewrites the journal it is undoing. + if !strings.Contains(string(devaJournal), "run-8f21e4") || + !strings.Contains(string(devaJournal), "notes/scratch.md") { + t.Fatalf("the undo edited the run's own journal — it must only ever append to the hub's:\n%s", devaJournal) + } + if _, err := storage.Get(context.Background(), p.ID+"/journal/hubdev.jsonl"); err != nil { + t.Fatalf("the hub journaled the undo somewhere other than its own journal: %v", err) + } +} diff --git a/internal/webapp/frontend/e2e/session-run.spec.ts b/internal/webapp/frontend/e2e/session-run.spec.ts index 0ffcb6d..313d70f 100644 --- a/internal/webapp/frontend/e2e/session-run.spec.ts +++ b/internal/webapp/frontend/e2e/session-run.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { login, wikiId } from "./helpers"; +import { login, wikiId, expectToast, READER } from "./helpers"; /* One agent run, both halves (BEA-98). History used to show only what a run CHANGED; the reads lived in a daily aggregate with no session dimension and @@ -43,3 +43,83 @@ test("a read-only row opens the file it names", async ({ page }) => { await page.locator(".hrun-read", { hasText: "index.md" }).click(); await expect(page).toHaveURL(new RegExp(`/${pid}/index.md`)); }); + +/* BEA-82: the run-wide verb the card was grouped for. Every row inside the + card already had an action; the header had none, so reverting a bad run + meant clicking file by file and hoping you got them all. + + Driven against the seeded run (session 8f21e4 on device `seed`: one file + rewritten, one created) and left exactly as it was found — the last step + undoes the undo, which is also the point: the undo is itself a run card. */ +test("undoing a whole run puts every file it touched back", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + + // Somebody edits a file the run created, AFTER the run. The confirm has to + // say that this change is about to be overwritten too — the one thing in + // this feature that can burn a teammate. + await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, { + data: "# Runbook\n\nEdited by a teammate after the run.\n", + }); + + await page.goto(`/${pid}/history`); + const card = page.locator(".hrun", { hasText: "claude-code session 8f21e4" }).first(); + await expect(card).toBeVisible(); + + // The header carries the action; the run's own rows still carry theirs. + await card.locator(".hrun-undo").click(); + const modal = page.locator(".modal"); + await expect(modal).toContainText("Undo this run?"); + // Every path the run touched, with what will happen to each. + const rows = modal.locator(".undo-row"); + await expect(rows).toHaveCount(2); + await expect(rows.filter({ hasText: "notes/readme.md" })).toContainText("restore to pre-run version"); + await expect(rows.filter({ hasText: "runbook.md" })).toContainText("remove (the run created it)"); + // ...and the warning, named out loud. + await expect(modal.locator(".undo-warn")).toContainText("changed by someone else after this run"); + + // It writes to every synced device, so Cancel has to mean nothing happened. + await modal.getByRole("button", { name: "Cancel" }).click(); + await page.goto(`/${pid}/runbook.md`); + await expect(page.locator("#content")).toContainText("Edited by a teammate"); + + await page.goto(`/${pid}/history`); + await card.locator(".hrun-undo").click(); + await page.locator(".modal .danger-btn").click(); + await expectToast(page, /Undid 2 files/); + + // The file the run edited holds its pre-run content again, and the file it + // created is gone. + await page.goto(`/${pid}/notes/readme.md`); + await expect(page.locator("#content")).toContainText("Nested folder content"); + await page.goto(`/${pid}/runbook.md`); + await expect(page.locator("#content")).toContainText("isn't in this project"); + + // The undo is itself a run card — same note on every op it wrote — so it + // carries the same button and walks the whole thing back. + await page.goto(`/${pid}/history`); + const undoCard = page.locator(".hrun", { hasText: "undo run 8f21e4" }).first(); + await expect(undoCard).toBeVisible(); + await undoCard.locator(".hrun-undo").click(); + await page.locator(".modal .danger-btn").click(); + await expectToast(page, /Undid 2 files/); + await page.goto(`/${pid}/notes/readme.md`); + await expect(page.locator("#content")).toContainText("Rewritten during the agent run"); + + // Put the fixture back: the teammate's post-run edit above is the one thing + // the round trip legitimately restored, and later specs read this file. + await page.request.put(`/api/p/${pid}/upload/content?path=runbook.md`, { + data: "# Runbook\n\nCreated during the agent run.\n", + }); +}); + +// A write action, so a read-only member gets no button rather than one that +// 403s — the same rule the per-row restore and remove follow. +test("a read-only member sees no undo button on a run card", async ({ page }) => { + await login(page, READER); + const pid = await wikiId(page); + await page.goto(`/${pid}/history`); + const card = page.locator(".hrun").first(); + await expect(card).toBeVisible(); + await expect(card.locator(".hrun-undo")).toHaveCount(0); +}); diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 1a0843b..b8bee85 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -245,3 +245,15 @@ export interface UploadPlan { method?: string; headers?: Record; } + +// POST .../undo-run (handleUndoRun, undorun.go). The same shape answers a +// `preview: true` call and the real one, so the dialog and the result read +// from one type — and the plan the dialog showed is recomputed server-side +// before anything is written. +export interface UndoPlan { + ok: boolean; + undone: { path: string; action: "restore" | "remove" }[]; + skipped: string[]; // already at their pre-run content: nothing to write + changed_after: string[]; // someone landed a change on this path after the run + refused: string[]; // a path the hub's own upload door would refuse +} diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index bee9a9b..eb434a0 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -9,7 +9,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { atLeast } from "../api/types"; import { getJSON, postJSON } from "../api/http"; -import type { Project, ServerConfig } from "../api/types"; +import type { Project, ServerConfig, UndoPlan } from "../api/types"; import { useHeat, useTree } from "../hooks/useBrowse"; import { useShares } from "../hooks/useHub"; import { urlForPath, urlForView, type Route } from "../router"; @@ -30,6 +30,7 @@ import { Palette, type PaletteItem } from "../components/Palette"; import { ConnectGuide } from "../components/ConnectGuide"; import { Insights, useInsightsDevices } from "../components/Insights"; import { HistoryView, historyTitle } from "../components/HistoryView"; +import type { Run } from "../lib/runs"; import { VersionBanner } from "../components/VersionBanner"; // The hub's six share-time credential rules, in words. Only one caller @@ -340,6 +341,98 @@ export default function Browser(props: { [apiBase, qc], ); + /* ---- undo a whole agent run ---- + The run-wide form of the two above, and the reason the feed groups runs + at all: reverting a bad run used to mean clicking file by file and hoping + you got them all. + + Two calls, both to the same endpoint. The first (`preview: true`) writes + nothing and asks the SERVER which paths the run touched and what would + happen to each — the loaded window is paged and filterable, so a list + computed from what is on screen is wrong exactly when the run is old or + filtered. The second does it, recomputing the plan server-side rather + than trusting the one the dialog showed; an op that lands between the two + makes the confirm one op stale, never the write wrong. + + The warning block is the one thing here that can burn someone: a path a + teammate changed AFTER the run is reverted too. That is the model + (last-writer-wins, and per-row restore already behaves this way), so the + dialog says it out loud instead of the undo being a surprise. */ + const [undoingRun, setUndoingRun] = useState(""); + const onUndoRun = useCallback( + async (run: Run) => { + const id = run.session || run.note; + const sel = run.session + ? { session: run.session, device: run.entries[0]?.device?.id } + : { note: run.note, device: run.entries[0]?.device?.id }; + setUndoingRun(id); + try { + const plan = await postJSON(apiBase + "undo-run", { ...sel, preview: true }); + const after = new Set(plan.changed_after); + if (!plan.undone.length) { + toast("Nothing to undo — every file this run touched already holds its pre-run content."); + return; + } + const ok = await modalConfirm( + "Undo this run?", + <> +
+ {run.note || id} — {plan.undone.length} file + {plan.undone.length === 1 ? "" : "s"} +
+
+ {plan.undone.map((a) => ( +
+ {a.path} + {after.has(a.path) && changed after this run} + + {a.action === "remove" ? "remove (the run created it)" : "restore to pre-run version"} + +
+ ))} +
+ {after.size > 0 && ( +
+ {after.size} file{after.size === 1 ? " was" : "s were"} changed by someone else after + this run. Undoing overwrites {after.size === 1 ? "that change" : "those changes"} too. +
+ )} + {plan.skipped.length > 0 && ( +
+ {plan.skipped.length} already hold{plan.skipped.length === 1 ? "s" : ""} its pre-run + content and will be left alone. +
+ )} + {/* Never silently drop a category: a path the hub's own upload + door refuses is left out of the undo, and a dialog that + listed only what it WILL do would read as "all of it". */} + {plan.refused.length > 0 && ( +
+ {plan.refused.length} path{plan.refused.length === 1 ? "" : "s"} can't be written by + the hub and will be left alone: {plan.refused.join(", ")}. +
+ )} + , + "Undo run", + true, + ); + if (!ok) return; + const done = await postJSON(apiBase + "undo-run", sel); + qc.invalidateQueries({ queryKey: ["history", apiBase] }); + qc.invalidateQueries({ queryKey: ["tree", apiBase] }); + qc.invalidateQueries({ queryKey: ["render", apiBase] }); + qc.invalidateQueries({ queryKey: ["text"] }); + const skipped = done.skipped.length ? `, skipped ${done.skipped.length} (already current)` : ""; + toast(`Undid ${done.undone.length} file${done.undone.length === 1 ? "" : "s"}${skipped}.`); + } catch (err) { + toast("Undo failed: " + (err as Error).message, true); + } finally { + setUndoingRun(""); + } + }, + [apiBase, qc], + ); + const historyNow = useCallback(() => { if (!path) return openHistory(""); openHistory(isDir ? path + "/" : path); @@ -446,6 +539,7 @@ export default function Browser(props: { onRendered={onRendered} restore={canRestore ? { onRestore, busy: restoring } : undefined} remove={canRestore ? { onRemove, busy: removing } : undefined} + undoRun={canRestore ? { onUndoRun, busy: undoingRun } : undefined} filters={route.filters} /* push, not replace: a filter is a navigation, and Back undoes it */ onFilters={(f) => navigate(urlForView("history", project?.id, route.viewTarget || "", f))} diff --git a/internal/webapp/frontend/src/components/HistoryView.tsx b/internal/webapp/frontend/src/components/HistoryView.tsx index dc889aa..9251e22 100644 --- a/internal/webapp/frontend/src/components/HistoryView.tsx +++ b/internal/webapp/frontend/src/components/HistoryView.tsx @@ -9,6 +9,17 @@ import { groupRuns, runFileCount, type Run } from "../lib/runs"; import { HistoryFilters, authorsOf } from "./HistoryFilters"; import { historyFilterQuery, hasHistoryFilters, type HistoryFilters as Filters } from "../router"; +// Undoing a WHOLE run — the run-wide form of restore/remove, and the only +// action the card header carries. Absent when the viewer can't write, like +// its two per-row siblings, so a read-only member never sees a button that +// 403s. The card hands over the run itself, not a file list: which paths are +// reverted is worked out server-side, because this window is paged and +// filtered and a client-computed list is wrong exactly when the run is old. +export type UndoRunAction = { + onUndoRun: (run: Run) => void; + busy?: string; // the session (or note) currently in flight +}; + /* ---- history ---- Every change ever made, straight from the journals: who (account), when, from which device (name, OS, IP as the server saw it). The route stores @@ -30,12 +41,13 @@ export function HistoryView(props: { onRendered?: () => void; restore?: RestoreAction; remove?: RemoveAction; + undoRun?: UndoRunAction; // Reader filters, straight from the URL. Applied server-side, so they // narrow the whole feed and not just the loaded page. filters?: Filters; onFilters?: (f: Filters) => void; }) { - const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, filters } = props; + const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, undoRun, filters } = props; const q = !target ? { prefix: "" } : isFolder(target) @@ -150,6 +162,7 @@ export function HistoryView(props: { recreates={recreates} restore={restore} remove={remove} + undoRun={undoRun} /> ) : ( void; @@ -206,6 +220,7 @@ function RunGroup({ recreates: (i: number) => boolean; restore?: RestoreAction; remove?: RemoveAction; + undoRun?: UndoRunAction; }) { const [open, setOpen] = useState(true); const first = run.entries[0]; @@ -236,6 +251,7 @@ function RunGroup({ // Distinct paths, not ops: repeat edits to one file must not inflate the // one number that sizes a run (BEA-39). Every op is still a row below. const n = runFileCount(run); + const undoing = !!undoRun?.busy && undoRun.busy === (run.session || run.note); return (
@@ -259,6 +275,22 @@ function RunGroup({ {dev ? " · " + dev : ""} {span} + {/* The one action the header carries. Every row inside the card + already has its own; this is the verb the card was grouped for — + reverting a run file by file and hoping you got them all is what + it replaces. onUndoRun confirms before anything is written. */} + {undoRun && ( + + )}
{open && (
diff --git a/internal/webapp/frontend/src/modal.tsx b/internal/webapp/frontend/src/modal.tsx index 08db116..d063c47 100644 --- a/internal/webapp/frontend/src/modal.tsx +++ b/internal/webapp/frontend/src/modal.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useSyncExternalStore } from "react"; +import { type ReactNode, useRef, useState, useSyncExternalStore } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -26,7 +26,11 @@ type Prompt = { type Confirm = { kind: "confirm"; title: string; - message: string; + // A node, not a string: the run-wide undo has to SHOW the file list and the + // "changed after this run" warning it is asking about, and a confirm whose + // text can't hold them would push that list somewhere the user has to go + // find. Every existing caller passes a string, which is a ReactNode. + message: ReactNode; confirmLabel: string; danger: boolean; resolve: (v: boolean) => void; @@ -54,7 +58,7 @@ export function modalPrompt( export function modalConfirm( title: string, - message: string, + message: ReactNode, confirmLabel = "Confirm", danger = false, ): Promise { @@ -170,7 +174,8 @@ function ConfirmBody({ m }: { m: Confirm }) {

{m.title}

-

{m.message}

+ {/* a div, not a p: a p may not legally contain the path list */} +
{m.message}