Skip to content
Open
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
47 changes: 34 additions & 13 deletions src/extension-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,25 +193,41 @@ export type AtelierDocumentsApi = {
* scrolling, so hosts control spacing and clipping. Previews load their own
* content from the given identity; a live target stays live.
*
* One shape covers every read surface:
* - neither `targetCommitId` nor `diff` — the live document;
* - `targetCommitId` alone — the document as of that commit;
* - `diff` alone — the change from its base to the live document;
* - both — the change between two commits.
* Every diff pins both sides. A working diff carries the exact HOT epoch that
* certified its bytes; an immutable diff names its target commit. There is no
* "base commit to implicit live target" mode because that can combine rows
* from different repository generations.
*/
export type AtelierFilePreviewProps = {
type AtelierFilePreviewIdentity = {
readonly lix: Lix;
readonly fileId: string;
readonly filePath: string;
/** Render the document as of this commit; absent — the live document. */
readonly targetCommitId?: string | null;
/**
* Render the change from this base to the target (or live) state. A null
* base means the file was added — the before side is empty by definition.
*/
readonly diff?: { readonly baseCommitId: string | null } | null;
};

export type AtelierFilePreviewProps = AtelierFilePreviewIdentity &
(
| {
/** No target and no diff renders the current live document. */
readonly targetCommitId?: null;
readonly diff?: null;
}
| {
/** Render an immutable target, optionally diffed from an immutable base. */
readonly targetCommitId: string;
readonly diff?: { readonly baseCommitId: string | null } | null;
}
| {
readonly targetCommitId?: null;
/** Render both sides from one certified HOT working epoch. */
readonly diff: {
readonly workingEpoch: {
readonly beforeCommitId: string;
readonly afterCommitId: string;
};
};
}
);

export type AtelierEvent =
| {
type: "document_open_attempted";
Expand Down Expand Up @@ -286,6 +302,11 @@ export type AtelierDiffFile = {
readonly changeKind: "added" | "modified" | "removed";
/** Set when a modified file's side paths differ: a move/rename. */
readonly movedFromPath?: string;
/** Certified HOT epoch for a mutable working diff. */
readonly workingEpoch?: {
readonly beforeCommitId: string;
readonly afterCommitId: string;
};
/** Present when the session reviews external writes (mutable target). */
readonly review?: {
readonly id: string;
Expand Down
32 changes: 31 additions & 1 deletion src/extension-runtime/external-write-review-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ describe("ExternalWriteReviewControls", () => {
// Both verbs apply to the selection.
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(undo).toHaveBeenCalledWith(["file-launch"]);
fireEvent.click(screen.getByRole("button", { name: "Checkpoint" }));
const checkpoint = await screen.findByRole("button", {
name: "Checkpoint",
});
await waitFor(() => expect(checkpoint).toBeEnabled());
fireEvent.click(checkpoint);
await waitFor(() => expect(primary).toHaveBeenCalledWith(["file-launch"]));
// Committing closes the list and restores the viewed-file default.
expect(screen.queryByRole("checkbox")).toBeNull();
Expand Down Expand Up @@ -240,6 +244,32 @@ describe("ExternalWriteReviewControls", () => {
expect(undo).not.toHaveBeenCalled();
});

test("surfaces a stale Undo rejection without an unhandled promise", async () => {
const undo = vi.fn(async () => {
throw new Error("The working diff changed. Reopen the review.");
});
render(
<ExternalWriteReviewControls
isActive
mode="working-changes"
navigation={{ ...NAVIGATION, fileCount: 1 }}
files={[FILES[0]]}
onUndo={undo}
onPrimary={vi.fn()}
/>,
);

const button = screen.getByRole("button", { name: "Undo" });
fireEvent.click(button);
await waitFor(() =>
expect(button).toHaveAttribute(
"title",
"The working diff changed. Reopen the review.",
),
);
expect(button).toBeEnabled();
});

test("puts the Esc Exit control at the far left of the float", () => {
const exit = vi.fn();
render(
Expand Down
20 changes: 18 additions & 2 deletions src/extension-runtime/external-write-review-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ export function ExternalWriteReviewControls({
readOnly,
selectionIds,
]);
const runUndo = useCallback(async () => {
if (readOnly || !onUndo || isCommitting || !hasSelection) return;
setCommitError(null);
setIsCommitting(true);
try {
await onUndo(selectionIds);
} catch (cause) {
setCommitError(
cause instanceof Error ? cause.message : "The action failed",
);
} finally {
setIsCommitting(false);
}
}, [hasSelection, isCommitting, onUndo, readOnly, selectionIds]);

useEffect(() => {
if (!isActive) return;
Expand Down Expand Up @@ -419,10 +433,12 @@ export function ExternalWriteReviewControls({
<button
type="button"
className="external-write-review-button external-write-review-button-reject"
onClick={() => void onUndo(selectionIds)}
onClick={() => void runUndo()}
disabled={readOnly || isCommitting || !hasSelection}
data-attr="diff-undo"
title={readOnly ? "Edit access is required" : undefined}
title={
readOnly ? "Edit access is required" : (commitError ?? undefined)
}
>
<RotateCcw aria-hidden="true" />
<span>Undo</span>
Expand Down
Loading