Skip to content

Commit afaaead

Browse files
pmaxhoganclaude
andcommitted
fix(m8-ui): cancel button + job-id reconcile + truncation notice (P1-1, P2-1, P2-4)
Frontend half of the M8 codex round-1 fixes, kept in sync with the backend contract: - P1-1: cancelRestoreJob IPC wrapper; store cancelRestore + cancelling flag; Restore.vue Cancel button (gated while cancelling), CANCELLED terminal label, and a per-file Cancelled state. RestoreJobStatus carries cancelled; RestoreFileState adds "cancelled". i18n keys added (cancel, cancelling, progress.cancelled, file.cancelled, truncated) - all via t(). - P2-1: listRemoteTree returns RemoteTreeDto { entries, truncated }; store tracks treeTruncated; Restore.vue shows a "showing first N" notice. - P2-4: store persists the returned activeJobId and reconciles via getRestoreJob(jobId) after start AND on (re)subscription, so a remount / missed terminal event recovers state instead of going stale. vitest: remount reconciles the active job by id; cancel invokes the IPC + reflects a terminal cancelled status. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent b166d8e commit afaaead

6 files changed

Lines changed: 303 additions & 22 deletions

File tree

ui/src/__tests__/restore-store.test.ts

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,16 @@ import { useRestoreStore } from "../stores/restore";
3535
import type {
3636
FileSearchHitDto,
3737
RemoteEntryDto,
38+
RemoteTreeDto,
3839
RestoreJobStatus,
3940
SourceDto,
4041
} from "../ipc/types";
4142

43+
/** Wrap entries as the RemoteTreeDto the backend now returns (M8-P2-1). */
44+
function tree(entries: RemoteEntryDto[], truncated = false): RemoteTreeDto {
45+
return { entries, truncated };
46+
}
47+
4248
function source(id: string, name: string): SourceDto {
4349
return {
4450
id,
@@ -98,7 +104,7 @@ describe("restore store", () => {
98104
if (cmd === "list_sources")
99105
return Promise.resolve([source("s1", "Documents")]);
100106
if (cmd === "list_remote_tree")
101-
return Promise.resolve([folder("src"), file("a.txt")]);
107+
return Promise.resolve(tree([folder("src"), file("a.txt")]));
102108
return Promise.resolve([]);
103109
});
104110

@@ -127,10 +133,10 @@ describe("restore store", () => {
127133
return Promise.resolve([source("s1", "Documents")]);
128134
if (cmd === "list_remote_tree") {
129135
const prefix = (args as { prefix: string }).prefix;
130-
if (prefix === "") return Promise.resolve([folder("src")]);
136+
if (prefix === "") return Promise.resolve(tree([folder("src")]));
131137
if (prefix === "src")
132-
return Promise.resolve([file("main.rs", "src")]);
133-
return Promise.resolve([]);
138+
return Promise.resolve(tree([file("main.rs", "src")]));
139+
return Promise.resolve(tree([]));
134140
}
135141
return Promise.resolve([]);
136142
});
@@ -160,7 +166,7 @@ describe("restore store", () => {
160166
invokeMock.mockImplementation((cmd: string) => {
161167
if (cmd === "list_sources")
162168
return Promise.resolve([source("s1", "Documents")]);
163-
if (cmd === "list_remote_tree") return Promise.resolve([]);
169+
if (cmd === "list_remote_tree") return Promise.resolve(tree([]));
164170
if (cmd === "search_files") return Promise.resolve(hits);
165171
return Promise.resolve([]);
166172
});
@@ -186,7 +192,7 @@ describe("restore store", () => {
186192
if (cmd === "list_sources")
187193
return Promise.resolve([source("s1", "Documents")]);
188194
if (cmd === "list_remote_tree")
189-
return Promise.resolve([file("a.txt"), file("b.txt")]);
195+
return Promise.resolve(tree([file("a.txt"), file("b.txt")]));
190196
return Promise.resolve([]);
191197
});
192198
const store = useRestoreStore();
@@ -210,12 +216,35 @@ describe("restore store", () => {
210216
});
211217

212218
it("restores selected files with a dialog token and accumulates progress to done", async () => {
219+
// The seeded snapshot getRestoreJob returns right after start (before any
220+
// live tick) - the reconcile path (M8-P2-4).
221+
const seeded: RestoreJobStatus = {
222+
jobId: "job-1",
223+
totalFiles: 1,
224+
completedFiles: 0,
225+
failedFiles: 0,
226+
totalBytes: 100,
227+
bytesDone: 0,
228+
currentFile: null,
229+
done: false,
230+
cancelled: false,
231+
files: [
232+
{
233+
relativePath: "secret.bin",
234+
state: "pending",
235+
bytesDone: 0,
236+
bytesTotal: 100,
237+
errorCode: null,
238+
},
239+
],
240+
};
213241
invokeMock.mockImplementation((cmd: string) => {
214242
if (cmd === "list_sources")
215243
return Promise.resolve([source("s1", "Documents")]);
216244
if (cmd === "list_remote_tree")
217-
return Promise.resolve([file("secret.bin")]);
245+
return Promise.resolve(tree([file("secret.bin")]));
218246
if (cmd === "restore_files") return Promise.resolve("job-1");
247+
if (cmd === "get_restore_job") return Promise.resolve(seeded);
219248
return Promise.resolve([]);
220249
});
221250

@@ -250,6 +279,7 @@ describe("restore store", () => {
250279
bytesDone: 50,
251280
currentFile: "secret.bin",
252281
done: false,
282+
cancelled: false,
253283
files: [
254284
{
255285
relativePath: "secret.bin",
@@ -297,4 +327,134 @@ describe("restore store", () => {
297327
store.unsubscribeProgress();
298328
expect(unlistenMock).toHaveBeenCalledTimes(1);
299329
});
330+
331+
it("reconciles the active job by id on (re)subscription after a remount (M8-P2-4)", async () => {
332+
// Simulate: a restore was started (activeJobId set), then the view remounts
333+
// and re-subscribes. subscribeProgress must fetch getRestoreJob(jobId) so the
334+
// current state is recovered even if a terminal event was missed.
335+
const terminal: RestoreJobStatus = {
336+
jobId: "job-9",
337+
totalFiles: 1,
338+
completedFiles: 1,
339+
failedFiles: 0,
340+
totalBytes: 100,
341+
bytesDone: 100,
342+
currentFile: null,
343+
done: true,
344+
cancelled: false,
345+
files: [
346+
{
347+
relativePath: "a.bin",
348+
state: "done",
349+
bytesDone: 100,
350+
bytesTotal: 100,
351+
errorCode: null,
352+
},
353+
],
354+
};
355+
invokeMock.mockImplementation((cmd: string) => {
356+
if (cmd === "list_sources")
357+
return Promise.resolve([source("s1", "Documents")]);
358+
if (cmd === "list_remote_tree")
359+
return Promise.resolve(tree([file("a.bin")]));
360+
if (cmd === "restore_files") return Promise.resolve("job-9");
361+
if (cmd === "get_restore_job") return Promise.resolve(terminal);
362+
return Promise.resolve([]);
363+
});
364+
365+
const store = useRestoreStore();
366+
await store.loadSources();
367+
store.toggleSelect("s1", "a.bin");
368+
store.setDestination("/home/u/restored", "tok-1");
369+
await store.startRestore();
370+
expect(store.activeJobId).toBe("job-9");
371+
372+
// Remount: unsubscribe then re-subscribe; the re-subscribe reconciles by id.
373+
store.unsubscribeProgress();
374+
await store.subscribeProgress();
375+
376+
const getCall = invokeMock.mock.calls.find(
377+
(c) => c[0] === "get_restore_job",
378+
);
379+
expect(getCall?.[1]).toMatchObject({ job: "job-9" });
380+
// The reconciled terminal state is reflected (controls re-enabled).
381+
expect(store.job?.done).toBe(true);
382+
expect(store.restoring).toBe(false);
383+
});
384+
385+
it("cancels the active job and reflects a terminal cancelled status (M8-P1-1)", async () => {
386+
invokeMock.mockImplementation((cmd: string) => {
387+
if (cmd === "list_sources")
388+
return Promise.resolve([source("s1", "Documents")]);
389+
if (cmd === "list_remote_tree")
390+
return Promise.resolve(tree([file("big.bin")]));
391+
if (cmd === "restore_files") return Promise.resolve("job-c");
392+
if (cmd === "cancel_restore_job") return Promise.resolve(null);
393+
if (cmd === "get_restore_job")
394+
return Promise.resolve({
395+
jobId: "job-c",
396+
totalFiles: 1,
397+
completedFiles: 0,
398+
failedFiles: 0,
399+
totalBytes: 100,
400+
bytesDone: 0,
401+
currentFile: "big.bin",
402+
done: false,
403+
cancelled: false,
404+
files: [
405+
{
406+
relativePath: "big.bin",
407+
state: "restoring",
408+
bytesDone: 0,
409+
bytesTotal: 100,
410+
errorCode: null,
411+
},
412+
],
413+
} as RestoreJobStatus);
414+
return Promise.resolve([]);
415+
});
416+
417+
const store = useRestoreStore();
418+
await store.subscribeProgress();
419+
await store.loadSources();
420+
store.toggleSelect("s1", "big.bin");
421+
store.setDestination("/home/u/restored", "tok-c");
422+
await store.startRestore();
423+
expect(store.activeJobId).toBe("job-c");
424+
425+
// Request cancel: the cancel IPC is invoked with the job id; cancelling gates.
426+
await store.cancelRestore();
427+
const cancelCall = invokeMock.mock.calls.find(
428+
(c) => c[0] === "cancel_restore_job",
429+
);
430+
expect(cancelCall?.[1]).toMatchObject({ job: "job-c" });
431+
expect(store.cancelling).toBe(true);
432+
433+
// The backend emits a terminal CANCELLED status; the store clears the flags.
434+
const cancelled: RestoreJobStatus = {
435+
jobId: "job-c",
436+
totalFiles: 1,
437+
completedFiles: 0,
438+
failedFiles: 0,
439+
totalBytes: 100,
440+
bytesDone: 30,
441+
currentFile: null,
442+
done: true,
443+
cancelled: true,
444+
files: [
445+
{
446+
relativePath: "big.bin",
447+
state: "cancelled",
448+
bytesDone: 30,
449+
bytesTotal: 100,
450+
errorCode: null,
451+
},
452+
],
453+
};
454+
progressHandler?.(cancelled);
455+
expect(store.job?.cancelled).toBe(true);
456+
expect(store.job?.done).toBe(true);
457+
expect(store.restoring).toBe(false);
458+
expect(store.cancelling).toBe(false);
459+
});
300460
});

ui/src/ipc/commands.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
PickedPath,
2626
ReauthSession,
2727
ReleaseDto,
28-
RemoteEntryDto,
28+
RemoteTreeDto,
2929
RestoreItem,
3030
RestoreJobId,
3131
RestoreJobStatus,
@@ -227,7 +227,7 @@ export function activitySummary(
227227
export function listRemoteTree(
228228
sourceId: string,
229229
prefix: string,
230-
): Promise<RemoteEntryDto[]> {
230+
): Promise<RemoteTreeDto> {
231231
return invoke("list_remote_tree", { sourceId, prefix });
232232
}
233233

@@ -258,3 +258,11 @@ export function restoreFiles(
258258
export function getRestoreJob(job: RestoreJobId): Promise<RestoreJobStatus> {
259259
return invoke("get_restore_job", { job });
260260
}
261+
262+
/** Cancel a running restore job (SPEC s11.5; M8-P1-1). The backend stops the job
263+
* between frames, DELETES any in-flight temp file (no partial left), and emits a
264+
* terminal CANCELLED status on `restore:progress`. Idempotent: cancelling an
265+
* unknown / already-finished job is a no-op. */
266+
export function cancelRestoreJob(job: RestoreJobId): Promise<void> {
267+
return invoke("cancel_restore_job", { job });
268+
}

ui/src/ipc/types.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,14 @@ export interface RemoteEntryDto {
351351
restorable: boolean;
352352
}
353353

354+
/** The result of `listRemoteTree` (mirrors src-tauri RemoteTreeDto, M8-P2-1):
355+
* the immediate children plus a `truncated` flag so the UI can tell the user the
356+
* listing was capped (rather than silently dropping children past the cap). */
357+
export interface RemoteTreeDto {
358+
entries: RemoteEntryDto[];
359+
truncated: boolean;
360+
}
361+
354362
/** One Restore search hit (mirrors src-tauri FileSearchHitDto). */
355363
export interface FileSearchHitDto {
356364
sourceId: string;
@@ -371,8 +379,14 @@ export interface RestoreItem {
371379
export type RestoreJobId = string;
372380

373381
/** Per-file lifecycle state within a restore job (mirrors src-tauri
374-
* RestoreFileState). */
375-
export type RestoreFileState = "pending" | "restoring" | "done" | "failed";
382+
* RestoreFileState). `cancelled` (M8-P1-1) means the user cancelled before this
383+
* file finished; any partial temp was deleted (no half-written file). */
384+
export type RestoreFileState =
385+
| "pending"
386+
| "restoring"
387+
| "done"
388+
| "failed"
389+
| "cancelled";
376390

377391
/** Per-file progress within a restore job (mirrors src-tauri
378392
* RestoreFileProgress). `errorCode` is a stable SPEC s24 i18n key when failed. */
@@ -397,5 +411,9 @@ export interface RestoreJobStatus {
397411
bytesDone: number;
398412
currentFile: string | null;
399413
done: boolean;
414+
/** `true` when the job's terminal state is a user CANCELLATION (M8-P1-1).
415+
* `done && !cancelled` is a normal finish; `done && cancelled` means the job
416+
* was stopped early and any in-flight temp file was deleted (no partial). */
417+
cancelled: boolean;
400418
files: RestoreFileProgress[];
401419
}

ui/src/locales/en-US.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@
273273
"tree": "No backed-up files in this folder yet.",
274274
"search": "No files match your search."
275275
},
276+
"truncated": "Showing the first {count} items in this folder. Use search to find more.",
276277
"node": {
277278
"folder": "Folder",
278279
"select": "Select file to restore",
@@ -282,17 +283,21 @@
282283
"clearSelection": "Clear selection",
283284
"pickDestination": "Choose destination...",
284285
"start": "Restore selected",
286+
"cancel": "Cancel restore",
287+
"cancelling": "Cancelling...",
285288
"progress": {
286289
"running": "Restoring...",
287290
"done": "Restore complete",
291+
"cancelled": "Restore cancelled",
288292
"summary": "{completed} of {total} done, {failed} failed",
289293
"current": "Restoring: {file}"
290294
},
291295
"file": {
292296
"pending": "Pending",
293297
"restoring": "Restoring",
294298
"done": "Done",
295-
"failed": "Failed"
299+
"failed": "Failed",
300+
"cancelled": "Cancelled"
296301
}
297302
},
298303
"errors": {

0 commit comments

Comments
 (0)