import type { JsonValue } from "@berry/api"; import type { ViewContext, ViewDestination } from "@berry/api/web.ts"; import type { DiffEditor, Layout } from "./editor.ts"; import { type Draft, draftKey, editId, loadDrafts, recover, type Stored, storeDrafts, } from "./drafts.ts"; import type { OpenFile, Saved, WorkingCopy } from "./edit-types.ts"; import type { FileDiff } from "./types.ts"; type Loaded = { diffEditor: typeof import("./editor.ts").diffEditor; merge3: typeof import("./merge.ts").merge3; }; let loaded: Loaded | null = null; let loading: Promise | null = null; /** CodeMirror and the merge are most of the bundle, and only editing needs them. */ function load(): Promise { loading ??= Promise.all([import("./editor.ts"), import("./merge.ts")]).then( ([editor, merge]) => loaded = { diffEditor: editor.diffEditor, merge3: merge.merge3 }, (error) => { loading = null; throw new Error( `Could not load the editor; reload the page. (${ error instanceof Error ? error.message : error })`, ); }, ); return loading; } type Editing = { draft: Draft; element: HTMLElement; editor: DiffEditor; status: HTMLElement; save: HTMLButtonElement; cancel: HTMLButtonElement; force: HTMLButtonElement; saving: Promise | null; syncing: Promise | null; /** Whether the disk may have changed since the last look. */ stale: boolean; /** The disk's version, while it conflicts with the draft. */ conflict: OpenFile | null; resolve: HTMLElement; }; /** How long typing goes before its draft is written down for recovery. */ const TYPED_MS = 250; export function editing( redraw: (path: string) => void, refreshed: (path: string) => void, failure: (message: string) => void, ) { let context: ViewContext; let key: string; let stored: Stored; let ready: Promise = Promise.resolve(); let disposed = false; let leaving: Promise | null = null; /** A report leaving kicked off without holding navigation open, which a flush still waits for. */ let backgroundReport: Promise | null = null; let destination: ViewDestination; let cancelChoice: (() => void) | null = null; const edits = new Map(); const opening = new Map>(); /** The view's route a draft belongs to: its jj change, or the working copy's empty one. */ const routeOf = (draft: Draft) => draft.opened.working.change ?? ""; const dirty = (draft: Draft) => draft.content !== draft.opened.content || !!draft.pending; const staying = () => destination.tid === context.tid && destination.extension === "diff" && destination.route === context.route; const exiting = () => destination.tid !== context.tid || destination.extension !== "diff"; /** A keystroke's write, held back a moment: each writes every draft whole, which per key is too slow to type in. */ let typed: ReturnType | undefined; const persist = () => { clearTimeout(typed); typed = undefined; try { storeDrafts(key, stored); } catch { failure("Draft recovery storage is full. Keep this tab open until your edits are saved."); } }; function close(path: string) { edits.get(path)?.editor.destroy(); edits.delete(path); delete stored.drafts[path]; persist(); refreshed(path); } async function save(edit: Editing): Promise { if (edit.saving) return await edit.saving; if (!dirty(edit.draft)) { close(edit.draft.opened.file.path); return true; } const { draft } = edit; draft.pending ??= { session: stored.id, id: editId(), working: draft.opened.working, path: draft.opened.file.path, version: draft.opened.version, content: draft.content, }; stored.saved = true; persist(); edit.save.disabled = true; edit.cancel.disabled = true; edit.status.textContent = "saving…"; edit.force.hidden = true; let changedOnDisk = false; const task = (async () => { try { const saved = await context.request( "save", draft.pending as unknown as JsonValue, ) as unknown as Saved; draft.opened.content = draft.pending!.content; draft.opened.version = saved.version; draft.opened.working = saved.working; delete draft.pending; if (!stored.saves.some((receipt) => receipt.id === saved.id)) stored.saves.push(saved); stored.saved = true; persist(); if (dirty(draft)) { edit.status.textContent = "unsaved edits"; refreshed(draft.opened.file.path); } else { close(draft.opened.file.path); } return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); changedOnDisk = message.includes("since you opened it"); edit.force.hidden = !message.includes("would cause conflicts"); edit.status.textContent = edit.force.hidden ? message : `${message} Save anyway to keep them?`; return false; } finally { edit.save.disabled = false; edit.cancel.disabled = false; edit.saving = null; } })(); edit.saving = task; const saved = await task; if (changedOnDisk) { await sync(edit); const current = edits.get(draft.opened.file.path); if (current && !current.conflict) { current.status.textContent = "since you opened this file, it changed. your edits have been merged."; } } return saved; } /** Bring changes made on disk since the draft's base into the editor, as a rebase would. */ function sync(edit: Editing): Promise { edit.stale = true; return edit.syncing ??= (async () => { try { while (edit.stale && !disposed && edits.get(edit.draft.opened.file.path) === edit) { await edit.saving; edit.stale = false; await pull(edit); } } finally { edit.syncing = null; } })(); } async function pull(edit: Editing) { const { draft } = edit; const path = draft.opened.file.path; let latest: OpenFile; try { latest = await context.request("open", { root: draft.opened.working.root, path, route: routeOf(draft), }) as unknown as OpenFile; } catch (error) { edit.status.textContent = `Could not check the file on disk: ${ error instanceof Error ? error.message : error }`; return; } if (disposed || edits.get(path) !== edit || edit.saving) return; if (latest.version === draft.opened.version) { // a commit rewritten around the file still moves the draft to the new commit draft.opened.working = latest.working; return persist(); } // a save whose answer was lost may have landed, and is then the base const base = draft.pending?.content === latest.content ? latest.content : draft.opened.content; const merged = loaded!.merge3(base, draft.content, latest.content); if (merged === false) { edit.conflict = latest; edit.resolve.hidden = false; edit.status.textContent = "The file changed on disk where you edited it."; return; } adopt( edit, latest, merged, merged === latest.content ? "updated from disk" : "merged changes from disk", false, ); } /** Take `latest` as the draft's base, with `content` as its text; a new original or working copy remounts it. */ function adopt( edit: Editing, latest: OpenFile, content: string, message: string, undoable: boolean, ) { const { draft } = edit; const path = draft.opened.file.path; const moved = latest.original !== draft.opened.original || (Object.keys(latest.working) as (keyof WorkingCopy)[]).some((key) => latest.working[key] !== draft.opened.working[key] ); edit.conflict = null; edit.resolve.hidden = true; delete draft.pending; draft.opened = latest; if (moved) { edit.editor.destroy(); edits.delete(path); draft.content = content; persist(); redraw(path); } else { edit.editor.replace(content, undoable); draft.content = content; persist(); } const current = edits.get(path); if (current) current.status.textContent = message; } function mount(draft: Draft, layout: Layout): Editing { const status = as HTMLElement; let escaped = 0; const surface =
as HTMLElement; const saveButton = ( ) as HTMLButtonElement; const force = ( ) as HTMLButtonElement; const cancel = ( ) as HTMLButtonElement; const element = (
{status} {force} {cancel} {saveButton}
{surface}
) as HTMLElement; const editor = loaded!.diffEditor( surface, { ...draft.opened, content: draft.content }, layout, { changed(content) { escaped = 0; draft.content = content; status.textContent = dirty(draft) ? "unsaved edits" : "saved"; typed ??= setTimeout(persist, TYPED_MS); }, save: () => void save(edit), // a second press soon after the first is the confirmation that cancel asks for escape() { if (edit.saving) return; if (Date.now() - escaped < 1500) return close(draft.opened.file.path); escaped = Date.now(); status.textContent = dirty(draft) ? "press Escape again to discard your edits" : "press Escape again to exit edit mode"; }, }, ); const edit: Editing = { draft, element, editor, status, save: saveButton, cancel, force, saving: null, syncing: null, stale: false, conflict: null, resolve: element.querySelector(".diff-conflict") as HTMLElement, }; status.textContent = dirty(draft) ? "recovered draft · unsaved edits" : "editing new side"; edits.set(draft.opened.file.path, edit); return edit; } function choose(): Promise<"save" | "discard" | "stay"> { return new Promise((resolve) => { const dialog = (

You have unsaved diff edits.

) as HTMLDialogElement; const finish = (choice: "save" | "discard" | "stay") => { cancelChoice = null; dialog.close(); dialog.remove(); resolve(choice); }; cancelChoice = () => finish("stay"); for ( const [label, choice] of [ ["save and leave", "save"], ["discard and leave", "discard"], ["stay", "stay"], ] as const ) { dialog.append( , ); } dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish("stay"); }); document.body.append(dialog); dialog.showModal(); }); } async function leave(): Promise { await ready; await Promise.all([...edits.values()].map((edit) => edit.saving)); if (staying()) return true; if (Object.values(stored.drafts).some(dirty)) { const choice = await choose(); if (choice === "stay") return false; if (choice === "save") { try { await load(); } catch (error) { failure(error instanceof Error ? error.message : String(error)); return false; } for (const draft of Object.values(stored.drafts)) { const edit = edits.get(draft.opened.file.path) ?? mount(draft, "unified"); while (dirty(draft)) if (!await save(edit)) return false; } } } if (staying()) return true; if (exiting() && stored.saved) { // Reporting saved edits can wait for the agent's turn to end, which must not hold navigation open. const id = stored.id; const saves = stored.saves; stored.id = editId(); stored.saved = false; stored.saves = []; persist(); const task = report(id, saves); backgroundReport = task; void task.finally(() => { if (backgroundReport === task) backgroundReport = null; }); } if (staying()) return true; for (const path of Object.keys(stored.drafts)) close(path); if (exiting()) localStorage.removeItem(key); return true; } /** Report saved edits without holding navigation open; a failure stays to retry on a later visit. */ async function report(id: string, saves: Saved[], throwOnFailure = false) { try { await context.command("diff-finish", JSON.stringify({ session: id, saves })); } catch (error) { const message = `Could not report your saved edits: ${ error instanceof Error ? error.message : error }`; const restore = () => { stored.id = id; stored.saved = true; stored.saves = [ ...saves, ...stored.saves.filter((receipt) => !saves.some((save) => save.id === receipt.id)), ]; persist(); }; if (disposed) { try { if (localStorage.getItem(key) === null) restore(); } catch { /* keep the navigation the user already made */ } console.error(`[berry] ${message}`); } else { restore(); failure(message); } if (throwOnFailure) throw error instanceof Error ? error : new Error(message); } } /** * Report saved edits while staying in the view, so an idle message sends after them. Dirty drafts stay * unsaved; a failure keeps the message in the composer by throwing. */ async function flush(): Promise { await ready; await Promise.all([...edits.values()].map((edit) => edit.saving)); if (disposed) return; if (backgroundReport) await backgroundReport; if (disposed || !stored?.saved) return; const id = stored.id; const saves = stored.saves; stored.id = editId(); stored.saved = false; stored.saves = []; persist(); if (!saves.length) return; await report(id, saves, true); } const onUnload = (event: BeforeUnloadEvent) => { if (stored && Object.values(stored.drafts).some(dirty)) { event.preventDefault(); event.returnValue = ""; } }; const onHide = () => { if (!stored) return; stored.closed = true; persist(); }; globalThis.addEventListener("beforeunload", onUnload); globalThis.addEventListener("pagehide", onHide); return { update(next: ViewContext) { context = next; if (!key) { key = draftKey(next.tid); ready = recover(key, next).catch((error) => failure(String(error))).then(() => { stored = loadDrafts(key); if (!disposed) { for (const path of Object.keys(stored.drafts)) redraw(path); } }); } }, async begin(file: FileDiff, root: string, line?: number) { await ready; const route = context.route; try { const editor = load(); editor.catch(() => {}); if (!stored.drafts[file.path]) { const request = opening.get(file.path) ?? context.request("open", { root, path: file.path, route }) as Promise; opening.set(file.path, request); const opened = await request.finally(() => opening.delete(file.path)); if (disposed || context.route !== route) return; stored.drafts[file.path] ??= { opened, content: opened.content }; persist(); } await editor; if (disposed || context.route !== route) return; redraw(file.path); const view = edits.get(file.path)?.editor.view; if (view) { if (line) { view.dispatch({ selection: { anchor: view.state.doc.line(Math.min(line, view.state.doc.lines)).from, }, }); } view.focus(); } } catch (error) { failure(error instanceof Error ? error.message : String(error)); } }, /** Look at the file on disk again, as when its diff changes. */ sync(path: string) { const edit = edits.get(path); if (edit) void sync(edit); }, has(path: string, route?: string) { const draft = stored?.drafts[path]; return !!draft && (route === undefined || routeOf(draft) === route); }, files(root: string, route: string): FileDiff[] { return Object.values(stored?.drafts ?? {}).filter((draft) => draft.opened.working.root === root && routeOf(draft) === route ) .map((draft) => draft.opened.file); }, attach(body: HTMLElement, file: FileDiff, layout: Layout, route: string) { const draft = stored?.drafts[file.path]; if (!draft || routeOf(draft) !== route) return false; let edit = edits.get(file.path); if (!edit && !loaded) { body.replaceChildren(

loading editor…

); load().then( () => redraw(file.path), (error) => failure(error instanceof Error ? error.message : String(error)), ); return true; } if (!edit) { edit = mount(draft, layout); void sync(edit); } edit.editor.layout(layout); if (!body.contains(edit.element)) body.replaceChildren(edit.element); return true; }, beforeLeave(next: ViewDestination) { destination = next; if (staying()) { cancelChoice?.(); if (!leaving) return true; } leaving ??= leave().finally(() => { leaving = null; }); return leaving; }, flush, dispose() { if (typed !== undefined) persist(); disposed = true; cancelChoice?.(); for (const edit of edits.values()) edit.editor.destroy(); edits.clear(); globalThis.removeEventListener("beforeunload", onUnload); globalThis.removeEventListener("pagehide", onHide); }, }; }