feat(files): multi-select delete + quick-delete toggle on /core/files/

Adds checkbox column with select-all header, a selection action bar showing
count and "Delete X selected" (one confirm for the whole batch, sequential
delete with progress tracking), and a "Quick delete" checkbox that suppresses
the per-file confirm dialog for single-file deletes. Selection clears on
each new search. Designed for rapid orphan cleanup after Check Orphans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-23 20:17:29 -04:00
parent c18b32c2d6
commit dcc0f9a05b
2 changed files with 143 additions and 5 deletions

View File

@@ -122,7 +122,7 @@ Sorting baseline is now `build_tmp_sort` (ASC chain, no `.reverse()` on tmp-sort
- [ ] **[Backend] `event_file` — add `cfg_json` column (post-CMSC)** — The per-file display override currently uses a localStorage workaround (`launcher_loc.current.file_display_overrides`) because `event_file` has no JSON blob column. Proper fix: add `cfg_json` to the `event_file` DB table, expose it through the FastAPI model, then migrate the frontend back to reading/writing the backend field (restoring global/cross-device persistence). Frontend code is in `launcher_file_cont.svelte` — search for `file_display_overrides`.
- [x] **[Backend] Hosted file delete — V3 CRUD regression fix** — `DELETE /v3/action/event_file/{id}` now handles atomic cleanup (link removal, physical file, hosted_file record). Frontend updated to use this endpoint directly (2026-06-18, commit `5689bfebb`).
- [x] **[Backend] Hosted file orphan scan endpoint** — `GET /v3/action/hosted_file/orphan_scan` live. `/core/files/` "Check Orphans" updated to use it — single call replaces N+1 per-file link fetches (2026-06-18, commit `5689bfebb`).
- [ ] **[Files] Multi-select delete + quick delete on `/core/files/`** — Two options to explore: (1) checkbox multi-select with a "Delete Selected" bulk action, (2) a toggle to suppress the per-file confirm dialog for faster single-file cleanup sessions. Useful for clearing orphan backlog after running Check Orphans.
- [x] **[Files] Multi-select delete + quick delete on `/core/files/`** — Implemented (2026-06-23): checkbox column with select-all header, selection action bar with "Delete X selected" (one confirm for the batch, sequential delete with progress tracking), and "Quick delete" toggle to suppress per-file confirms on single deletes.
- [ ] **[Backend] Re-add `Access-Control-Allow-Private-Network: true` CORS header.**
- [x] **[DevOps] Service worker `skipWaiting` + `clients.claim`** — Root cause of "users see old code / can't reproduce in dev testing": the SW sat in waiting state until all tabs closed. IDAA members leave idaa.org open all day. Fixed 2026-06-03: both calls added to `src/service-worker.js`. See mistake #16 in `BOOTSTRAP__AI_Agent_Quickstart.md`.
- [ ] **[DevOps] Nginx proxy buffer tuning** — Buffer settings copied from PHP guide; not optimal for Node.js. `proxy_busy_buffers_size` technically exceeds safe limit. Re-examine when enabling compression (now re-enabled) stabilizes.

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import {
ArrowUpDown,
@@ -74,6 +74,13 @@ let loading = $state(false);
let searched = $state(false);
let deleting_id = $state<string | null>(null);
// ── Multi-select ──────────────────────────────────────────────────────────────
let selected_ids = new SvelteSet<string>();
let bulk_deleting = $state(false);
let bulk_delete_progress = $state<{ done: number; total: number } | null>(null);
// When true, single-file deletes skip the confirm dialog.
let skip_confirm = $state(false);
// Auto-load on first render once the API config is ready
$effect(() => {
if (!$ae_api?.base_url || searched || loading) return;
@@ -114,6 +121,7 @@ async function do_search(reset = true) {
});
results = result_li ?? [];
selected_ids.clear();
searched = true;
loading = false;
}
@@ -164,7 +172,7 @@ async function handle_delete(file: ae_HostedFile) {
? `\n\nThis file has ${link_count} link${link_count !== 1 ? 's' : ''} (${file_links.map((l) => l.link_to_type).join(', ')}) — all will be removed.`
: '\n\nThis file has no links and will be deleted immediately.';
if (!confirm(`Delete "${label}"?${link_summary}\n\nThe physical file will be removed from disk. This cannot be undone.`))
if (!skip_confirm && !confirm(`Delete "${label}"?${link_summary}\n\nThe physical file will be removed from disk. This cannot be undone.`))
return;
deleting_id = id;
@@ -383,6 +391,84 @@ async function check_all_for_orphans() {
orphan_checking = false;
}
}
// ── Multi-select logic ────────────────────────────────────────────────────────
// Must be declared after displayed_results (which depends on orphan_filter/results).
let all_visible_selected = $derived(
displayed_results.length > 0 &&
displayed_results.every(f => selected_ids.has(f.hosted_file_id))
);
function toggle_select(id: string) {
if (selected_ids.has(id)) selected_ids.delete(id);
else selected_ids.add(id);
}
function toggle_select_all() {
if (all_visible_selected) {
for (const f of displayed_results) selected_ids.delete(f.hosted_file_id);
} else {
for (const f of displayed_results) selected_ids.add(f.hosted_file_id);
}
}
async function handle_bulk_delete() {
const ids_to_delete = displayed_results
.filter(f => selected_ids.has(f.hosted_file_id))
.map(f => f.hosted_file_id);
if (ids_to_delete.length === 0) return;
if (!confirm(`Delete ${ids_to_delete.length} file${ids_to_delete.length !== 1 ? 's' : ''}?\n\nPhysical files will be removed from disk. This cannot be undone.`))
return;
bulk_deleting = true;
bulk_delete_progress = { done: 0, total: ids_to_delete.length };
for (const id of ids_to_delete) {
try {
if (!links_map.has(id)) {
try {
const result = await api.get_object({ api_cfg: $ae_api, endpoint: `/v3/action/hosted_file/${id}/links`, log_lvl: 0 });
links_map.set(id, result?.data ?? result ?? []);
} catch {
links_map.set(id, []);
}
}
const file_links = links_map.get(id) ?? [];
if (file_links.length > 0) {
for (let i = 0; i < file_links.length; i++) {
const lnk = file_links[i];
await api.delete_hosted_file({
api_cfg: $ae_api,
hosted_file_id: id,
link_to_type: lnk.link_to_type,
link_to_id: lnk.link_to_id_random ?? undefined,
rm_orphan: i === file_links.length - 1,
params: { method: 'delete' },
log_lvl: 0
});
}
} else {
await api.delete_hosted_file({
api_cfg: $ae_api,
hosted_file_id: id,
rm_orphan: true,
params: { method: 'delete' },
log_lvl: 0
});
}
links_map.delete(id);
selected_ids.delete(id);
results = results.filter(r => r.hosted_file_id !== id);
} catch (e) {
console.error(`[bulk delete] ${id}`, e);
}
bulk_delete_progress = { done: (bulk_delete_progress?.done ?? 0) + 1, total: ids_to_delete.length };
}
bulk_deleting = false;
bulk_delete_progress = null;
}
</script>
<div class="space-y-6">
@@ -527,6 +613,12 @@ async function check_all_for_orphans() {
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
</span>
<div class="flex flex-wrap items-center gap-2">
<!-- Quick delete toggle — skips the per-file confirm dialog for single deletes -->
<label class="flex cursor-pointer items-center gap-1.5 text-xs opacity-70 hover:opacity-100"
title="Quick delete: skip the confirm dialog when deleting individual files">
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={skip_confirm} />
Quick delete
</label>
<!-- Orphan check -->
{#if orphan_filter}
<button
@@ -577,10 +669,48 @@ async function check_all_for_orphans() {
</div>
</div>
<!-- Selection action bar — visible only when rows are checked -->
{#if selected_ids.size > 0}
<div class="border-surface-500/20 bg-error-500/10 flex flex-wrap items-center gap-3 border-b px-4 py-2">
<span class="text-error-700-300 text-xs font-bold">
{selected_ids.size} file{selected_ids.size !== 1 ? 's' : ''} selected
</span>
<button
type="button"
class="btn btn-sm preset-filled-error text-xs"
onclick={handle_bulk_delete}
disabled={bulk_deleting}
title="Delete all selected files (one confirmation for the whole batch)">
{#if bulk_deleting && bulk_delete_progress}
<LoaderCircle size={12} class="mr-1 animate-spin" />
Deleting {bulk_delete_progress.done}/{bulk_delete_progress.total}
{:else}
<Trash2 size={12} class="mr-1" />
Delete {selected_ids.size} selected
{/if}
</button>
<button
type="button"
class="btn btn-sm preset-tonal-surface text-xs"
onclick={() => selected_ids.clear()}
disabled={bulk_deleting}>
<X size={12} class="mr-1" /> Clear selection
</button>
</div>
{/if}
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-surface-500/20 border-b text-left text-[10px] font-bold uppercase tracking-wider opacity-50">
<th class="px-3 py-2 w-8">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={all_visible_selected}
onchange={toggle_select_all}
title="Select / deselect all visible files" />
</th>
<th class="px-3 py-2">
<button type="button" class="flex items-center gap-1 hover:opacity-100" onclick={() => toggle_sort('filename')}>
Filename
@@ -629,7 +759,15 @@ async function check_all_for_orphans() {
{#each displayed_results as file (file.hosted_file_id)}
<tr
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
class:opacity-50={!file.enable}>
class:opacity-50={!file.enable}
class:bg-error-100={selected_ids.has(file.hosted_file_id)}>
<td class="px-3 py-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={selected_ids.has(file.hosted_file_id)}
onchange={() => toggle_select(file.hosted_file_id)} />
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-1.5">
<File size={12} class="shrink-0 opacity-40" />
@@ -725,7 +863,7 @@ async function check_all_for_orphans() {
{#if links_map.has(file.hosted_file_id)}
{@const file_links = links_map.get(file.hosted_file_id) ?? []}
<tr class="border-surface-500/10 border-b bg-surface-50-950/50">
<td colspan="6" class="px-4 py-2">
<td colspan="7" class="px-4 py-2">
{#if file_links.length === 0}
<span class="text-[10px] italic opacity-40">No links found — file may be an orphan.</span>
{:else}