fix(downloads): keep recovery instructions open while copying

This commit is contained in:
4gray committed 2026-09-08 08:40:16 +02:00
1 parent 9f91e074aa
commit 08421cc184
8 files changed
+102 -4

No files matched your search

@@ -736,6 +736,18 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l
exact: true,
})
).toBeVisible();
await app.mainWindow.evaluate(() => {
Object.defineProperty(navigator.clipboard, 'writeText', {
configurable: true,
value: () =>
Promise.reject(new Error('simulated clipboard failure')),
});
});
await recoveryDialog
.getByRole('button', { name: 'Copy recovery path', exact: true })
.click();
await expect(recoveryDialog).toBeVisible();
await expect(recoveryDialog).toContainText(capturedPath);
expect(readFileSync(capturedPath, 'utf8')).toBe(
'foreign recovery content'
);
+1
View File
@@ -121,6 +121,7 @@ the capture and its journal for recovery. Even after a foreign entry is restored
its private recovery copy is never automatically unlinked: another process could
remove the public link first. Remove/Clear return a structured recovery path and open a persistent, localized
dialog with the full path, Copy recovery path and manual recovery instructions.
Copy keeps the dialog open, including on clipboard failure; Close dismisses it.
After the user recovers it and explicitly removes that private copy, cleanup may release the
journal. Ordinary owned-file cleanup remains automatic. Remove/Clear, Retry/Resume and fresh
reservations retry identity-verified cleanup, including after restart and on
@@ -208,6 +208,7 @@ export class DownloadManagerActionsService {
private showRecovery(path: string): void {
this.dialogs.openConfirmDialog({
title: this.translate.instant('DOWNLOADS.RECOVERY.TITLE'),
keepOpenOnConfirm: true,
cancelLabel: this.translate.instant('CLOSE'),
width: '600px',
message: this.translate.instant('DOWNLOADS.RECOVERY.MESSAGE', {
@@ -668,6 +668,7 @@ describe('DownloadsComponent', () => {
title: 'File recovery needed',
message: expect.stringContaining(recoveryPath),
confirmLabel: 'Copy recovery path',
keepOpenOnConfirm: true,
cancelLabel: 'Close',
})
);
@@ -0,0 +1,54 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ConfirmDialogComponent } from './confirm-dialog.component';
describe('ConfirmDialogComponent actions', () => {
it.each([false, true])(
'keeps the action in place only when requested: %s',
async (keepOpenOnConfirm) => {
const onConfirm = jest.fn();
const close = jest.fn();
await TestBed.configureTestingModule({
imports: [
ConfirmDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'Recovery',
message: '/saved/entry',
confirmLabel: 'Copy',
cancelLabel: 'Close',
keepOpenOnConfirm,
onConfirm,
},
},
{ provide: MatDialogRef, useValue: { close } },
],
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDialogComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll(
'button'
) as NodeListOf<HTMLButtonElement>;
buttons[1].click();
if (keepOpenOnConfirm) {
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(close).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain(
'/saved/entry'
);
} else {
expect(onConfirm).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledWith(true);
}
buttons[0].click();
expect(close).toHaveBeenCalled();
}
);
});
@@ -9,6 +9,8 @@ export interface ConfirmDialogData {
width?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */
keepOpenOnConfirm?: boolean;
onConfirm: () => void;
}
@@ -25,9 +27,23 @@ export interface ConfirmDialogData {
<button mat-button mat-dialog-close cdkFocusInitial>
{{ dialogData.cancelLabel || 'NO' | translate }}
</button>
<button mat-flat-button [mat-dialog-close]="true" color="primary">
{{ dialogData.confirmLabel || 'YES' | translate }}
</button>
@if (dialogData.keepOpenOnConfirm) {
<button
mat-flat-button
(click)="dialogData.onConfirm()"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
</button>
} @else {
<button
mat-flat-button
[mat-dialog-close]="true"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
</button>
}
</mat-dialog-actions>
`,
})
@@ -83,4 +83,15 @@ describe('DialogService', () => {
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('does not repeat a persistent dialog action on programmatic close', () => {
const onConfirm = jest.fn();
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
service.openConfirmDialog({
title: 'Recovery',
message: 'Path',
keepOpenOnConfirm: true,
onConfirm,
});
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,8 @@ export class DialogService {
});
dialogRef
.afterClosed()
.subscribe((result) => (result ? data.onConfirm() : null));
.subscribe((result) =>
result && !data.keepOpenOnConfirm ? data.onConfirm() : null
);
}
}