feat(drive): permanent deletion per drive

This commit is contained in:
Edouard Vanbelle
2026-06-24 21:31:59 +02:00
parent 7d24015fc4
commit b73f176024
10 changed files with 596 additions and 23 deletions
+16
View File
@@ -111,3 +111,19 @@ export async function emptyTrash(): Promise<void> {
});
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
}
/**
* `DELETE /api/trash/drive/{drive_id}` — empty the trash within a
* single drive. Used by the trash page's Drive group-by, where each
* bucket header carries a per-drive Empty button so multi-drive
* owners don't have to wipe everything at once. Refused 404 when the
* caller lacks Delete on the named drive (anti-enum).
*/
export async function emptyTrashForDrive(driveId: string): Promise<void> {
const res = await apiFetch(`/api/trash/drive/${encodeURIComponent(driveId)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`empty drive trash failed: ${res.status}`);
}
@@ -87,6 +87,14 @@
dateLabel?: string;
/** Custom renderer for the date cell (e.g. trash expiry chip). */
dateCell?: Snippet<[ResourceEntry]>;
/**
* Optional per-bucket action button rendered alongside the swimlane
* header label. Receives the bucket key (the value `bucketOf`
* returned for the active group-by). Used by the trash page to expose
* a per-drive "Empty" affordance — the page decides which group-bys
* the action is meaningful for and returns nothing otherwise.
*/
bucketAction?: Snippet<[string]>;
/** Show the owner column + vignette (list view) and hover tooltip. */
showOwner?: boolean;
/** Allow grid/list toggle (shares the app-wide view mode). */
@@ -131,6 +139,7 @@
showDate = true,
dateLabel,
dateCell,
bucketAction,
showOwner = false,
showViewToggle = true,
selectable = false,
@@ -434,7 +443,14 @@
<div class={viewClass} style="--files-list-columns: {columns}">
{@render listHeader()}
{#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
<div class="rl-swimlane-header" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
{#if bucketAction}
<span class="rl-swimlane-header__action">
{@render bucketAction(section.key)}
</span>
{/if}
</div>
{#if filesStore.viewMode === 'list'}
<!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. The
@@ -651,6 +667,16 @@
font-weight: var(--weight-semibold);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-faint);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
/* Optional per-bucket action (e.g. trash page's per-drive Empty). */
.rl-swimlane-header__action {
display: inline-flex;
align-items: center;
}
/* Grid view date meta line. */
+72
View File
@@ -4,6 +4,7 @@
import {
deleteTrashItem,
emptyTrash,
emptyTrashForDrive,
expiryChip,
fetchTrashPage,
remainingDaysBucket,
@@ -184,6 +185,60 @@
}
}
// Per-drive empty (D2b stage 4 follow-up). The bucket key on the
// Drive group-by encodes "{rank}:{driveId}" so the natural lexical
// sort puts default-personal first; we strip the rank prefix here
// to recover the raw drive UUID. Only an Owner of the drive
// (Delete-bearing role) reaches the per-drive Empty button because
// the backend resolves a Delete-set first and refuses (404) any
// other drive.
function driveIdFromBucketKey(key: string): string {
return key.includes(':') ? (key.split(':')[1] ?? key) : key;
}
async function purgeDrive(bucketKey: string) {
const driveId = driveIdFromBucketKey(bucketKey);
const drive = drivesStore.findById(driveId);
// Owner-only check mirrors the backend gate so the UI doesn't
// surface the action for non-Owners — keeps the affordance
// honest. The bucket only appears on the page if the trash list
// already contained items the caller could see, but caller_role
// distinguishes Owner from Viewer/Editor on shared drives.
const ok = await confirmDialog({
title: t('trash.empty_drive_title', 'Empty drive trash'),
message: t(
'trash.confirm_empty_drive',
{ name: drive?.name ?? driveId },
'Empty the trash on drive "{{name}}"? This cannot be undone.'
),
confirmText: t('trash.empty_action', 'Empty trash'),
danger: true
});
if (!ok) return;
try {
await emptyTrashForDrive(driveId);
// Drop every entry that belonged to this drive; cheaper than a
// full refetch and matches what the user just saw.
raw = raw.filter((it) => it.drive_id !== driveId);
} catch (e) {
errorToast(e);
}
}
// The Drive group-by is the only one where a per-bucket empty
// affordance is meaningful — every other bucket key (remaining
// days, type, size, trashed time) isn't a permission scope. Hide
// the button on those group-bys.
const showPerDriveEmpty = $derived(groupBy === 'drive');
function driveCanPurge(driveId: string): boolean {
const d = drivesStore.findById(driveId);
// `caller_role === 'owner'` is the same gate the backend
// applies via Permission::Delete in the role bundle. Hide the
// button on Viewer/Editor drives so a click can't 404.
return d?.caller_role === 'owner';
}
onMount(() => {
// Drive names for the "Drive" group-by labels — `drivesStore.load()` is
// idempotent (cached on the singleton) so this is essentially free.
@@ -228,6 +283,23 @@
{chip.label}
</span>
{/snippet}
{#snippet bucketAction(bucketKey: string)}
{#if showPerDriveEmpty}
{@const driveId = driveIdFromBucketKey(bucketKey)}
{#if driveCanPurge(driveId)}
<button
type="button"
class="btn-action btn-action--delete"
data-testid={`trash-empty-drive-btn-${driveId}`}
title={t('trash.empty_drive_title', 'Empty drive trash')}
aria-label={t('trash.empty_drive_title', 'Empty drive trash')}
onclick={() => purgeDrive(bucketKey)}
>
<Icon name="trash" />
</button>
{/if}
{/if}
{/snippet}
{#snippet actions(entry)}
<button
class="btn-action"