refactor(ui): recent: add a quick button to remove item from recent

This commit is contained in:
Edouard Vanbelle
2026-07-20 01:10:04 +02:00
parent e3c3f6fe24
commit 63589e595e
19 changed files with 115 additions and 58 deletions
+17
View File
@@ -30,3 +30,20 @@ export async function clearRecent(): Promise<void> {
});
if (!res.ok) throw new Error(`clear recent failed: ${res.status}`);
}
/**
* Remove a single item from the caller's recent history — the "broom"
* per-row affordance in the recent view. Distinct from `clearRecent`
* (which wipes every entry). 404 means the item wasn't in recents to
* begin with — treated as a no-op success by the caller.
*/
export async function removeFromRecent(kind: ItemType, id: string): Promise<void> {
const res = await apiFetch(`/api/recent/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok && res.status !== 404) {
throw new Error(`remove from recent failed: ${res.status}`);
}
}
@@ -753,6 +753,11 @@
position: static;
width: 30px;
height: 30px;
/* `margin: 0` overrides the legacy `.files-grid-view .file-item
.btn-action { margin-top: var(--space-1) }` rule further down —
inside the corner cluster the parent's `gap` handles spacing
and any per-child margin would misalign the pills. */
margin: 0;
padding: 0;
border: none;
border-radius: var(--radius-full);
@@ -1188,6 +1193,11 @@
color: var(--color-text-dark);
}
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
these buttons flowed at the bottom of the card. Kept for any
free-standing use outside the corner cluster; reset inside
`.action-cell` (line ~745) so the broom / restore / delete pills
align with the kebab and star. */
.files-grid-view .file-item .btn-action {
margin-top: var(--space-1);
}
+56 -42
View File
@@ -5,14 +5,16 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import {
addFavorite,
clearRecent,
fetchRecentPage,
removeFromRecent,
type RecentResourceItem
} from '$lib/api/endpoints/recent';
import {
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
@@ -31,12 +33,10 @@
// `preferences.hideDotfiles` + `isDotfile` are read here only to
// derive `hiddenCount` for the empty-state message — the actual
// filter is inside ResourceList (gated on `showDotfileToggle`).
// `replaceSet` is from perf-round-6: `loadFavoriteIds` mutates
// the reactive SvelteSet in place instead of re-creating it.
import { preferences } from '$lib/stores/preferences.svelte';
import { isDotfile } from '$lib/utils/dotfileFilter';
import { replaceSet } from '$lib/utils/sets';
import { t } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
let raw = $state<RecentResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
@@ -45,9 +45,6 @@
let groupBy = $state('');
let reversed = $state(false);
const owners = useOwnerCache(resolveOwnerName);
// In-place reactive set — a star toggle skips the full-set copy and
// spares the other favorited rows' readers.
const favoriteIds = new SvelteSet<string>();
// Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId`
// (Recent's provenance semantic — "who touched this recently" — differs
@@ -62,7 +59,7 @@
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
// on every infinite-scroll page.
const contextMap = new SvelteMap<string, ItemContext>();
const hiddenCount = $derived(
preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0
@@ -104,18 +101,6 @@
}
];
async function loadFavoriteIds() {
try {
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
replaceSet(
favoriteIds,
favs.items.map((f) => f.resource.id)
);
} catch {
// non-fatal — stars just default to off
}
}
// Recent defaults to most-recently-accessed first (accessed_at DESC).
async function load(reset = false, orderBy = 'accessed_at', rev = reversed) {
loading = true;
@@ -173,24 +158,32 @@
viewerOpen = true;
}
// Callback signature is `FileItem | FolderItem` (ResourceList
// hands raw items to `onfavorite` — the pre-migration
// `ResourceEntry` shape is gone). Set mutation is in-place per
// perf-round-6: 1 000 toggles @ N=5 000 dropped from 771.9 ms
// to 1.9 ms by skipping the full-set copy that every reader of
// `favoriteIds` used to see.
async function toggleFavorite(item: FileItem | FolderItem) {
const isFav = favoriteIds.has(item.id);
/**
* Remove a single item from the caller's recent history. The
* per-row "broom" affordance replaces the favorite-star that
* existed here before — /recent is a history view, so surfacing
* "forget this one" is more useful than "favorite this one"
* (users go to the item's real home to favorite it).
*
* Optimistic: the row disappears immediately; if the DELETE
* fails, we re-add it at its original position and toast the
* error so the state stays honest.
*/
async function removeItem(item: FileItem | FolderItem) {
const kind = kindOf(item);
// Optimistic in-place toggle, reverted on failure.
if (isFav) favoriteIds.delete(item.id);
else favoriteIds.add(item.id);
const idx = raw.findIndex((it) => it.resource.id === item.id);
if (idx < 0) return;
const snapshot = raw[idx];
raw = raw.filter((it) => it.resource.id !== item.id);
contextMap.delete(item.id);
try {
if (isFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
await removeFromRecent(kind, item.id);
} catch (e) {
if (isFav) favoriteIds.add(item.id);
else favoriteIds.delete(item.id);
raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)];
contextMap.set(item.id, {
date: snapshot.accessed_at,
ownerId: snapshot.resource.updated_by ?? null
});
errorToast(e);
}
}
@@ -325,7 +318,6 @@
}
onMount(() => {
void loadFavoriteIds();
void load(true);
});
</script>
@@ -336,7 +328,6 @@
title={t('nav.recent', 'Recent')}
{items}
{contextMap}
{favoriteIds}
resolveOwnerName={(id) => owners.name(id)}
{loading}
{error}
@@ -354,7 +345,6 @@
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
showOwner
showPath
showDotfileToggle
@@ -397,6 +387,30 @@
onclick={() => batchDelete(sel)}>{t('common.delete', 'Delete')}</Button
>
{/snippet}
{#snippet itemActions(item)}
<!--
Per-row "broom" — remove this single item from the recent
history. Replaces the favorite star; on a history view a
"forget this one" affordance is more useful than a
favorite gesture. Grid view: the shared corner-cluster
CSS turns this into a 30x30 scrim pill sitting next to
the kebab in the top-right of the card. List view: same
`.btn-action` treatment as trash's Restore / Delete
buttons at the row's action-cell.
-->
<button
class="btn-action"
data-testid={`recent-remove-btn-${item.id}`}
title={t('recent.remove_item', 'Remove from recent')}
aria-label={t('recent.remove_item', 'Remove from recent')}
onclick={(e) => {
e.stopPropagation();
void removeItem(item);
}}
>
<Icon name="broom" />
</button>
{/snippet}
</ResourceList>
{#if fileViewer.component}
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
"empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك",
"empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.",
"loadMore": "تحميل المزيد"
"loadMore": "تحميل المزيد",
"remove_item": "إزالة من الأخيرة"
},
"notifications": {
"file_renamed": "تمت إعادة تسمية الملف",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt",
"empty_hidden_state": "{{n}} zuletzt verwendete(s) Element(e) durch Ihre Einstellung ausgeblendet",
"empty_hidden_hint": "Deaktivieren Sie \"Verborgene Dateien ausblenden\" in Ihrem Profil, um sie anzuzeigen.",
"loadMore": "Mehr laden"
"loadMore": "Mehr laden",
"remove_item": "Aus zuletzt verwendet entfernen"
},
"notifications": {
"file_renamed": "Datei umbenannt",
+2 -1
View File
@@ -754,7 +754,8 @@
"empty_hidden_state": "{{n}} recent item(s) hidden by your dotfile preference",
"empty_hidden_hint": "Turn off \"Hide dotfiles\" in your profile to see them.",
"loadMore": "Load more",
"confirm_clear": "Clear your recent items?"
"confirm_clear": "Clear your recent items?",
"remove_item": "Remove from recent"
},
"notifications": {
"file_renamed": "File renamed",
+2 -1
View File
@@ -588,7 +588,8 @@
"empty_hint": "Los archivos que abras aparecerán aquí",
"empty_hidden_state": "{{n}} elemento(s) reciente(s) oculto(s) por tu preferencia",
"empty_hidden_hint": "Desactiva \"Ocultar archivos ocultos\" en tu perfil para verlos.",
"loadMore": "Cargar más"
"loadMore": "Cargar más",
"remove_item": "Quitar de recientes"
},
"notifications": {
"file_renamed": "Archivo renombrado",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند",
"empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است",
"empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.",
"loadMore": "بارگذاری بیشتر"
"loadMore": "بارگذاری بیشتر",
"remove_item": "حذف از اخیر"
},
"batch": {
"one_selected": "۱ مورد انتخاب شده",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici",
"empty_hidden_state": "{{n}} élément(s) récent(s) masqué(s) par votre préférence",
"empty_hidden_hint": "Désactivez \"Masquer les fichiers\" dans votre profil pour les voir.",
"loadMore": "Charger plus"
"loadMore": "Charger plus",
"remove_item": "Retirer des récents"
},
"notifications": {
"file_renamed": "Fichier renommé",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
"empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं",
"empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।",
"loadMore": "और लोड करें"
"loadMore": "और लोड करें",
"remove_item": "हाल के से हटाएँ"
},
"notifications": {
"file_renamed": "फ़ाइल का नाम बदला गया",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "I file che apri appariranno qui",
"empty_hidden_state": "{{n}} elemento/i recente/i nascosto/i dalla tua preferenza",
"empty_hidden_hint": "Disattiva \"Nascondi i file nascosti\" nel tuo profilo per vederli.",
"loadMore": "Carica altri"
"loadMore": "Carica altri",
"remove_item": "Rimuovi dai recenti"
},
"notifications": {
"file_renamed": "File rinominato",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "開いたファイルがここに表示されます",
"empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります",
"empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。",
"loadMore": "さらに読み込む"
"loadMore": "さらに読み込む",
"remove_item": "最近使用したものから削除"
},
"notifications": {
"file_renamed": "ファイル名を変更しました",
+2 -1
View File
@@ -717,7 +717,8 @@
"empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개",
"empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.",
"loadMore": "더 불러오기",
"confirm_clear": "최근 항목을 지우시겠습니까?"
"confirm_clear": "최근 항목을 지우시겠습니까?",
"remove_item": "최근에서 제거"
},
"notifications": {
"file_renamed": "파일 이름이 변경되었습니다",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Bestanden die je opent verschijnen hier",
"empty_hidden_state": "{{n}} recent(e) item(s) verborgen door je voorkeur",
"empty_hidden_hint": "Schakel \"Verborgen bestanden verbergen\" uit in je profiel om ze te zien.",
"loadMore": "Meer laden"
"loadMore": "Meer laden",
"remove_item": "Uit recent verwijderen"
},
"notifications": {
"file_renamed": "Bestand hernoemd",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Otwarte pliki pojawią się tutaj",
"empty_hidden_state": "{{n}} ostatnich elementów ukrytych zgodnie z Twoją preferencją",
"empty_hidden_hint": "Wyłącz \"Ukryj ukryte pliki\" w swoim profilu, aby je zobaczyć.",
"loadMore": "Załaduj więcej"
"loadMore": "Załaduj więcej",
"remove_item": "Usuń z ostatnich"
},
"notifications": {
"file_renamed": "Zmieniono nazwę pliku",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Os arquivos que você abrir aparecerão aqui",
"empty_hidden_state": "{{n}} item(ns) recente(s) oculto(s) pela sua preferência",
"empty_hidden_hint": "Desative \"Ocultar arquivos ocultos\" no seu perfil para vê-los.",
"loadMore": "Carregar mais"
"loadMore": "Carregar mais",
"remove_item": "Remover dos recentes"
},
"notifications": {
"file_renamed": "Arquivo renomeado",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "Открытые вами файлы будут отображаться здесь",
"empty_hidden_state": "Недавних элементов скрыто: {{n}}",
"empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.",
"loadMore": "Загрузить ещё"
"loadMore": "Загрузить ещё",
"remove_item": "Удалить из недавних"
},
"notifications": {
"file_renamed": "Файл переименован",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "您開啟的檔案將顯示在這裡",
"empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目",
"empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。",
"loadMore": "載入更多"
"loadMore": "載入更多",
"remove_item": "從最近項目中移除"
},
"batch": {
"one_selected": "已選擇 1 個專案",
+2 -1
View File
@@ -583,7 +583,8 @@
"empty_hint": "您打开的文件将显示在这里",
"empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目",
"empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。",
"loadMore": "加载更多"
"loadMore": "加载更多",
"remove_item": "从最近使用中移除"
},
"batch": {
"one_selected": "已选择 1 个项目",