Merge pull request #629 from EdouardVanbelle/refactor/front-resource-list

Refactor(front) restore legacy frontend features
This commit is contained in:
Dionisio Pozo
2026-07-21 00:16:40 +02:00
committed by GitHub
55 changed files with 2911 additions and 1890 deletions
+4
View File
@@ -95,6 +95,8 @@ fn rows(n: usize) -> Vec<FolderResourceRow> {
} else {
Some("a".repeat(64))
},
created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()),
sort_str: format!("row {i}"),
type_order: 0,
folder_first: if is_folder { 0 } else { 1 },
@@ -262,6 +264,8 @@ fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
} else {
Some("a".repeat(64))
},
created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()),
is_owner: true,
favorited_at: ts,
path: Some(format!("Documents/Work/item-{i:05}")),
@@ -1,221 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
import { apiFetch } from '$lib/api/client';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders';
/**
* Benchmark gate for the coalesced progressive-render emissions in
* {@link fetchFolderListing}.
*
* Audit finding: the loader invoked `onPage` after EVERY 200-item page with a
* fresh copy of the whole accumulated listing, and the files view re-derives
* its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from
* each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200)
* elements re-sorted on the main thread during a single load — hundreds of ms
* of jank on exactly the large folders progressive rendering was meant to
* help. The fix emits page one (first paint) and the final page always, and
* intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS.
*
* Gates:
* 1. Equivalence — final listing identical to the emit-every-page reference,
* first emission still after page one (first paint preserved), last
* emission still `done === true` with the complete listing.
* 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side
* derive work collapses from 25 full re-sorts to ≤3; wall time of the
* load+derive cycle must drop accordingly (≥3x on the derive term).
*/
type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } };
type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
const PAGE_SIZE = 200;
const PAGES = 25; // 5 000-item folder
/** Deterministic shuffled names so the consumer sort actually works. */
function pageBody(page: number): ResourcePage {
const items: ResourceItem[] = [];
for (let i = 0; i < PAGE_SIZE; i++) {
const n = page * PAGE_SIZE + i;
const id = `f-${n.toString().padStart(5, '0')}`;
// Mix folders into the first page like a real listing (folders first).
const isFolder = page === 0 && i < 20;
items.push({
resource_type: isFolder ? 'folder' : 'file',
resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` }
});
}
return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined };
}
function fakeRes(body: ResourcePage): Response {
return {
status: 200,
ok: true,
json: async () => body,
headers: { get: () => null }
} as unknown as Response;
}
function mockPagedFetch(): void {
let call = 0;
vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++)));
}
/**
* The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy
* of the whole accumulated listing after every page.
*/
async function referenceFetchFolderListing(
folderId: string,
onPage: (partial: FolderListing, done: boolean) => void
): Promise<FolderListing> {
const folders: FolderItem[] = [];
const files: FileItem[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
if (cursor) params.set('cursor', cursor);
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
credentials: 'same-origin',
cache: 'no-store'
});
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const page = (await res.json()) as ResourcePage;
for (const it of page.items ?? []) {
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
else files.push(it.resource as FileItem);
}
cursor = page.next_cursor;
onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor);
} while (cursor);
return { folders, files, favoriteIds: [], sharedIds: [] };
}
/**
* The files view's per-emission derive chain, reduced to its dominant costs:
* dotfile filter pass + two localeCompare sorts + ordered-entry rebuild
* (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte).
* Returns the number of elements that went through the sort — the O(N²) term.
*/
function consumerDerive(partial: FolderListing): number {
const visF = partial.folders.filter((f) => !f.name.startsWith('.'));
const visX = partial.files.filter((f) => !f.name.startsWith('.'));
const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name));
const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name));
const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)];
return orderedIds.length;
}
beforeEach(() => {
vi.clearAllMocks();
invalidateFolderCache();
});
describe('coalesced progressive listing emissions (benchmark gate)', () => {
it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => {
mockPagedFetch();
const refEmits: Array<{ n: number; done: boolean }> = [];
const refFinal = await referenceFetchFolderListing('bench', (p, done) =>
refEmits.push({ n: p.folders.length + p.files.length, done })
);
mockPagedFetch();
const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = [];
const r = await fetchFolderListing('bench', {
onPage: (partial, done) =>
emits.push({ n: partial.folders.length + partial.files.length, done, partial })
});
// Identical complete listing.
expect(r.listing).toEqual(refFinal);
// First paint unchanged: the first emission is still page one.
expect(emits[0].n).toBe(refEmits[0].n);
expect(emits[0].n).toBe(PAGE_SIZE);
// Exactly one done emission, last, carrying the full listing — as before.
expect(emits.filter((e) => e.done).length).toBe(1);
expect(emits[emits.length - 1].done).toBe(true);
expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE);
expect(refEmits[refEmits.length - 1].done).toBe(true);
// Emissions are a subset of what the reference produced (never more).
expect(emits.length).toBeLessThanOrEqual(refEmits.length);
// Every emitted partial is a prefix-accumulation (monotone growth).
for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n);
});
it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => {
vi.mocked(apiFetch).mockResolvedValue(
fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor
);
const emits: boolean[] = [];
await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) });
expect(emits).toEqual([true]);
});
it(
`collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`,
{ timeout: 30_000 },
async () => {
// Warm-up both paths, twice each, so V8's tiering has fully
// settled before we measure. A single warm-up was enough on
// developer laptops but bursty CPU steals on shared CI
// runners can leave one path un-tiered during measurement,
// skewing the wall-time ratio at line ~202 below.
for (let i = 0; i < 2; i++) {
mockPagedFetch();
await referenceFetchFolderListing('warm', (p) => consumerDerive(p));
mockPagedFetch();
await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) });
}
mockPagedFetch();
let refSorted = 0;
let refEmits = 0;
const t0 = performance.now();
await referenceFetchFolderListing('bench', (p) => {
refEmits++;
refSorted += consumerDerive(p);
});
const refMs = performance.now() - t0;
mockPagedFetch();
let sorted = 0;
let emitsN = 0;
const t1 = performance.now();
await fetchFolderListing('bench', {
onPage: (p) => {
emitsN++;
sorted += consumerDerive(p);
}
});
const ms = performance.now() - t1;
console.info(
`progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)`
);
// The reference re-derived every page: Σ = P(P+1)/2 pages of elements.
expect(refEmits).toBe(PAGES);
expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2);
// Coalesced: page 1 + final (+ occasionally one mid emission if the
// stubbed pages ever take >150 ms — they don't on any healthy runner).
expect(emitsN).toBeLessThanOrEqual(3);
// ≥5x less consumer sort work is the point of the change.
// This is a pure DETERMINISTIC count (sum of `consumerDerive`
// return values) — hardware-independent, so catches an
// actual O(N²) → O(N) regression cleanly.
expect(sorted).toBeLessThan(refSorted / 5);
// And it must show up as wall time on the combined load+
// derive cycle. 2x floor (loosened from 3x on 2026-07-18
// after a shared-CI-runner false alarm at 2.63x — bursty
// CPU steals eat headroom on the fine-grained
// `performance.now()` measurements). Still catches an
// O(N²) regression (which would be ~10x slower, not 2x)
// — the deterministic count above at line 200 is the real
// algorithmic gate.
expect(ms).toBeLessThan(refMs / 2);
}
);
});
+90 -67
View File
@@ -110,86 +110,109 @@ export function getFolder(id: string): Promise<FolderItem> {
return request;
}
/**
* Minimum spacing between intermediate progressive-render emissions of
* {@link fetchFolderListing}. Each emission hands the consumer the WHOLE
* accumulated listing, and the files view re-derives its filtered + sorted
* view from it (O(accumulated · log) with `localeCompare`), so emitting every
* page made a large-folder load Σ O(N²/page) of main-thread sort work. Page
* one and the final page always emit; pages in between only emit after this
* much time has passed since the previous emission.
*/
export const PAGE_EMIT_MIN_INTERVAL_MS = 150;
/** One page of `/api/folders/{id}/resources`. */
export interface FolderPage {
/**
* Items in the exact order the server returned them. Under `order_by=name`,
* `type`, `size` the server puts folders first, then files; under
* `modified_at` / `created_at` the two kinds interleave. Consumers that
* need to preserve the server sort MUST iterate this list — the split
* `folders` / `files` arrays lose the interleaving.
*/
items: (FolderItem | FileItem)[];
/** `items` filtered to folder rows (order preserved). */
folders: FolderItem[];
/** `items` filtered to file rows (order preserved). */
files: FileItem[];
/** Opaque cursor for the next page; `undefined` on the last page. */
nextCursor?: string;
}
/**
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
* `/listing` route was removed. We page through to the end (folders sort first
* under `order_by=name`) and split the mixed resource items back into
* `folders` / `files`.
* Fetch a single page of a folder's listing.
*
* That feed carries no whole-listing ETag, so the 304 conditional fast-path is
* gone: `opts.etag` is accepted for call-site compatibility but ignored, and the
* in-memory `folderCache` is what the views revalidate against. Favorite/share
* badge sets aren't part of this feed either, so they come back empty for now.
* `/files` uses this directly and drives its own pagination — the initial
* `load()` requests page one; the ResourceList's `onloadmore` (fired by an
* IntersectionObserver at the bottom sentinel) requests the next page with
* the previous `nextCursor` and appends the results. `orderBy` is passed
* through so pages come back in the requested server-side sort order; the
* caller resets state and refetches page one on sort/group change.
*
* The legacy `fetchFolderListing` (below) is a thin loop over this — kept
* for the move-dialog folder tree, which genuinely needs every child at
* once and doesn't have an infinite-scroll surface.
*/
export async function fetchFolderPage(
folderId: string,
opts: {
orderBy?: string;
reverse?: boolean;
cursor?: string;
limit?: number;
forceRefresh?: boolean;
} = {}
): Promise<FolderPage> {
const params = new URLSearchParams({
order_by: opts.orderBy ?? 'name',
limit: String(opts.limit ?? 200)
});
if (opts.reverse) params.set('reverse', 'true');
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.forceRefresh) params.set('force_refresh', 'true');
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
credentials: 'same-origin',
cache: 'no-store'
});
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const page = (await res.json()) as {
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
next_cursor?: string;
};
const items: (FolderItem | FileItem)[] = [];
const folders: FolderItem[] = [];
const files: FileItem[] = [];
for (const it of page.items ?? []) {
if (it.resource_type === 'folder') {
const f = it.resource as FolderItem;
folders.push(f);
items.push(f);
} else {
const f = it.resource as FileItem;
files.push(f);
items.push(f);
}
}
// Learn the children's names for breadcrumb resolution.
for (const f of folders) rememberFolderName(f.id, f.name);
return { items, folders, files, nextCursor: page.next_cursor };
}
/**
* Fetch a folder's complete listing (sub-folders + files) by walking every
* cursor page eagerly. Only the move-dialog tree still needs this shape —
* `/files` switched to {@link fetchFolderPage} for lazy scroll-driven paging.
*
* `opts.etag` is accepted for call-site compatibility but ignored (the
* `/resources` feed carries no whole-listing ETag). Favorite / share badge
* sets are unpopulated by this endpoint and come back empty.
*/
export async function fetchFolderListing(
folderId: string,
opts: {
etag?: string;
forceRefresh?: boolean;
/**
* Progressive render hook: invoked with the accumulated listing so
* far (the arrays are fresh copies — safe to hand to reactive
* state). Without it, a 2,000-item folder waited for all ⌈N/200⌉
* sequential round-trips before the first row painted; with it the
* view paints after page one (~200 items) and fills in as the tail
* pages land. Emissions are coalesced to at most one per
* {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final
* page — the hook is always called for page one and always called
* once more with `done === true` and the complete listing.
*/
onPage?: (partial: FolderListing, done: boolean) => void;
} = {}
opts: { etag?: string; forceRefresh?: boolean } = {}
): Promise<FolderListingResult> {
const folders: FolderItem[] = [];
const files: FileItem[] = [];
let cursor: string | undefined;
let firstPage = true;
let lastEmit = 0;
do {
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
if (opts.forceRefresh) params.set('force_refresh', 'true');
if (cursor) params.set('cursor', cursor);
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
credentials: 'same-origin',
cache: 'no-store'
const page = await fetchFolderPage(folderId, {
cursor,
forceRefresh: opts.forceRefresh
});
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const page = (await res.json()) as {
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
next_cursor?: string;
};
for (const it of page.items ?? []) {
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
else files.push(it.resource as FileItem);
}
cursor = page.next_cursor;
const done = !cursor;
if (
opts.onPage &&
(done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS)
) {
lastEmit = performance.now();
opts.onPage(
{ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] },
done
);
}
firstPage = false;
folders.push(...page.folders);
files.push(...page.files);
cursor = page.nextCursor;
} while (cursor);
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
}
+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}`);
}
}
@@ -0,0 +1,37 @@
<script lang="ts">
/**
* Two-slot action-bar layout used above every ResourceList surface.
*
* [ start-slot ] ← page actions / batch actions
* [ end-slot ] ← <DisplayModeControls /> — group-by, sort, view, dotfile
*
* This is pure layout — no state, no visual variation per section.
* It reuses the existing `.actions-bar` / `.action-buttons` classes
* defined globally in `styles/ported/content.css` so it renders
* identically to `<ListToolbar>` (the component it's replacing).
*
* Consumers pass whatever they want on either side; `<ResourceList>`
* uses this internally to wire its `actions` / `batchActions` /
* display-mode-controls snippets, and pages can also use it directly
* when they need a bespoke layout that doesn't fit ResourceList's
* default (e.g. `/files` upload split-button).
*/
import type { Snippet } from 'svelte';
interface Props {
/** Left cluster — page action buttons (or batch actions on
* selection). Omit to render an empty placeholder that still
* reserves the space, so the end cluster stays right-aligned. */
start?: Snippet;
/** Right cluster — usually a `<DisplayModeControls />` instance,
* but any content works. */
end?: Snippet;
}
let { start, end }: Props = $props();
</script>
<div class="actions-bar">
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
{#if end}{@render end()}{/if}
</div>
+6 -2
View File
@@ -872,7 +872,10 @@
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 50;
/* Search suggestions render above `.page-sticky-header` — otherwise the
dropdown clips under the action bar on the content pages. Design-token
`--z-dropdown` (1000) sits above `--z-sticky` (100) by construction. */
z-index: var(--z-dropdown);
list-style: none;
margin: 0;
padding: 0.25rem;
@@ -1117,7 +1120,8 @@
position: absolute;
bottom: calc(100% + 4px);
right: 0;
z-index: 60;
/* Sits above `--z-sticky` for the same reason as `.suggest` above. */
z-index: var(--z-dropdown);
min-width: 12rem;
max-height: 18rem;
overflow: auto;
@@ -0,0 +1,194 @@
<script lang="ts" module>
/** Reuses the existing GroupOption shape from `<ListToolbar>` so
* callers can pass the same `groupBys` arrays their pages already
* define. Duplicated here so consumers can import a coherent set
* without pulling in the legacy toolbar. */
export interface GroupOption {
key: string;
label: string;
icon?: string;
}
</script>
<script lang="ts">
/**
* Right-hand cluster of display-mode controls: group-by menu,
* sort-direction toggle, grid/list view toggle, hide-dotfiles eye.
*
* Every control is opt-in via its own `show…` prop so a section
* without one (e.g. `/trash` has no dotfile toggle by design) can
* omit the prop rather than pass an empty array or a no-op
* callback. State bindings pass through — the parent still owns
* `groupBy`, `reversed`, `viewMode`, etc.
*
* Style-wise this reuses the ported `.view-toggle` block + child
* classes (buttons.css) so it renders identically to the
* `<ListToolbar>` right cluster. That keeps every page's look
* consistent across the ResourceList migration.
*/
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
interface Props {
// ── Group-by ─────────────────────────────────────────────
/** Group-by dimensions; omit or empty array = hide the control. */
groups?: GroupOption[];
/** Active group-by key (controlled by the parent). */
groupBy?: string;
/** Fired when a group-by dimension is chosen. */
ongroup?: (key: string) => void;
// ── Sort direction ───────────────────────────────────────
/** Whether the sort direction is reversed. */
reversed?: boolean;
/** Fired when the sort-direction toggle is clicked. */
ondirection?: () => void;
/** Show the sort-direction toggle. Defaults to `true` when
* `groups` is non-empty (there's nothing to reverse otherwise). */
showSort?: boolean;
// ── View mode (grid / list) ─────────────────────────────
/** Show the grid/list view toggle. */
showViewMode?: boolean;
// ── Dotfile visibility ──────────────────────────────────
/** Show the hide-dotfiles eye toggle. Only makes sense on
* algorithmic listings (files, recent); off by default. */
showDotfileToggle?: boolean;
// ── Extension slot ──────────────────────────────────────
/** Rendered immediately before the group-by button, still
* inside `.view-toggle`. Kind-filter dropdowns and other
* page-local controls that want to sit alongside the
* built-ins land here. */
beforeGroupBy?: Snippet;
}
let {
groups,
groupBy = '',
ongroup,
reversed = false,
ondirection,
showSort,
showViewMode = false,
showDotfileToggle = false,
beforeGroupBy
}: Props = $props();
// Sort toggle defaults ON when a group-by list is provided —
// there's nothing to reverse without it.
const sortVisible = $derived(showSort ?? (groups?.length ?? 0) > 0);
const active = $derived(groups?.find((g) => g.key === groupBy) ?? groups?.[0]);
let menuOpen = $state(false);
$effect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.group-by-selector')) menuOpen = false;
};
window.addEventListener('pointerdown', onDown);
return () => window.removeEventListener('pointerdown', onDown);
});
function pick(key: string) {
menuOpen = false;
ongroup?.(key);
}
// Hide the whole cluster if nothing is enabled — the ActionBar
// then collapses to its start-only layout without an empty
// right block occupying space.
const anyVisible = $derived(
(groups?.length ?? 0) > 0 || showViewMode || showDotfileToggle || !!beforeGroupBy
);
</script>
{#if anyVisible}
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
{#if beforeGroupBy}{@render beforeGroupBy()}{/if}
{#if groups?.length}
<div class="group-by-selector" data-testid="display-mode-groupby-menu">
<button
class="toggle-btn group-by-btn active"
title={t('groupby.title', 'Group by')}
aria-haspopup="true"
aria-expanded={menuOpen}
data-testid="display-mode-groupby-btn"
onclick={() => (menuOpen = !menuOpen)}
>
<Icon name={active?.icon ?? 'layer-group'} />
<span class="group-by-label">{active?.label ?? ''}</span>
</button>
{#if sortVisible}
<button
class="toggle-btn sort-dir-btn"
class:active={reversed}
title={t('sortdir.title', 'Sort direction')}
aria-label={t('sort.direction', 'Sort direction')}
data-testid="display-mode-sort-direction-btn"
onclick={() => ondirection?.()}
>
<Icon name="arrow-up" />
</button>
{/if}
{#if menuOpen}
<div class="group-by-menu">
{#each groups as g (g.key)}
<button
class="group-by-option"
class:active={groupBy === g.key}
data-testid={`display-mode-groupby-${g.key}-item`}
onclick={() => pick(g.key)}
>
<Icon name={g.icon ?? 'layer-group'} />
{g.label}
</button>
{/each}
</div>
{/if}
</div>
{#if showViewMode}<span class="view-toggle-separator"></span>{/if}
{/if}
{#if showViewMode}
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'grid'}
title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'}
data-testid="display-mode-view-grid-btn"
onclick={() => filesStore.setViewMode('grid')}
>
<Icon name="th" />
</button>
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'list'}
title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'}
data-testid="display-mode-view-list-btn"
onclick={() => filesStore.setViewMode('list')}
>
<Icon name="list" />
</button>
{/if}
{#if showDotfileToggle}
<button
class="toggle-btn"
class:active={preferences.hideDotfiles}
title={preferences.hideDotfiles
? t('view.show_dotfiles', 'Show hidden files')
: t('view.hide_dotfiles', 'Hide hidden files')}
aria-pressed={preferences.hideDotfiles}
data-testid="display-mode-dotfile-toggle-btn"
onclick={() => preferences.toggleHideDotfiles()}
>
<Icon name={preferences.hideDotfiles ? 'eye-slash' : 'eye'} />
</button>
{/if}
</div>
{/if}
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,18 @@
{#each ui.toasts as toast (toast.id)}
<div class="toast toast--{toast.kind}" role="status" data-testid={`toaster-toast-${toast.id}`}>
<span class="toast__msg">{toast.message}</span>
{#if toast.action}
<button
class="toast__action"
data-testid={`toaster-action-btn-${toast.id}`}
onclick={() => {
toast.action?.onClick();
ui.dismiss(toast.id);
}}
>
{toast.action.label}
</button>
{/if}
<button
class="toast__close"
data-testid={`toaster-dismiss-btn-${toast.id}`}
@@ -73,6 +85,22 @@
flex: 1;
}
.toast__action {
flex-shrink: 0;
background: var(--color-accent);
color: var(--color-accent-contrast);
border: none;
border-radius: var(--radius-md);
padding: var(--space-1-5) var(--space-3);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
}
.toast__action:hover {
filter: brightness(0.95);
}
.toast__close {
background: none;
border: none;
+22 -2
View File
@@ -6,10 +6,19 @@
*/
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
export interface ToastAction {
/** Button label — should be short (≤ 20 chars). */
label: string;
/** Invoked when the button is clicked; the toast auto-dismisses after. */
onClick: () => void;
}
export interface Toast {
id: number;
message: string;
kind: ToastKind;
/** Optional inline action (e.g. "Go to Files" on a wrong-drop-zone toast). */
action?: ToastAction;
}
export interface Notification {
@@ -74,10 +83,21 @@ class UiStore {
/**
* Raise a toast and record a notification. `at` is stamped from the clock at
* call time; pass `record: false` for purely transient messages.
*
* The optional `opts.action` renders an inline button in the toast (e.g.
* "Go to Files" on a wrong-drop-zone warning); the callback fires on
* click and the toast auto-dismisses right after so a caller doesn't have
* to manage the id.
*/
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
notify(
message: string,
kind: ToastKind = 'info',
timeoutMs = 4000,
record = true,
opts: { action?: ToastAction } = {}
): number {
const id = ++this.#seq;
this.toasts = [...this.toasts, { id, message, kind }];
this.toasts = [...this.toasts, { id, message, kind, action: opts.action }];
if (record) {
this.notifications = [
{ id, message, kind, at: Date.now(), read: false },
@@ -21,13 +21,11 @@
margin-right: var(--space-3);
height: 60px;
transform: translateY(-8px);
transition:
opacity 0.2s,
max-height 0.25s,
transform 0.2s,
margin 0.2s,
padding 0.2s;
pointer-events: auto;
/* Note: previous versions of this rule animated the bar's
appearance (opacity / max-height / transform / margin / padding
transitions on the class-add). Dropped intentionally — the bar
just appears/disappears with the selection state now. */
}
.batch-bar-close {
+70 -51
View File
@@ -266,9 +266,12 @@
min-width: 0;
}
/* Size column: always nth-child(5) because .owner-cell is always in the DOM
(even when hidden via display:none, it still occupies a child slot). */
.list-header > div:nth-child(5),
/* Column alignment — targets classes on BOTH the header divs AND the value
cells, so the header label always matches its column's value alignment
regardless of which optional columns (path/type/owner/…) are on. The
previous shape keyed off `nth-child(N)` and drifted the moment a
ResourceList caller toggled a `show*` prop. */
.list-header > .size-cell,
.files-list-view .file-item .size-cell {
justify-self: end;
text-align: right;
@@ -325,7 +328,12 @@
vignette sized to its content and the cell clipped it flat with
no ellipsis. The cell's own `text-overflow` still ellipses
plain-text fallback content (cells without a vignette child). */
.owner-cell {
/* Scoped to `.file-item` so the header div — which also carries the
`.owner-cell` class now (so column-alignment CSS keys off classes
instead of brittle nth-child indices) — doesn't inherit the muted
cell colour / cell font size. Header keeps `.list-header`'s
semibold + text colour. */
.file-item .owner-cell {
color: var(--color-text-secondary);
font-size: var(--text-base);
display: flex;
@@ -427,7 +435,7 @@
flex-shrink: 0;
}
.list-header > div:nth-child(5),
.list-header > .date-cell,
.files-list-view .file-item .date-cell {
justify-self: center;
text-align: center;
@@ -708,13 +716,35 @@
outline-offset: 2px;
}
/* More actions button (three dots) — top-right of the thumbnail on a scrim. */
/* Grid cards surface actions through the corner kebab (.file-actions) + the
favorite star, both absolutely positioned below. The inline per-row action
buttons (share/move/rename/delete) belong to the list view only — hide them
here so they don't stack up along the bottom edge of the card. */
.files-grid-view .file-item .action-cell .btn-action {
display: none;
/* ── Grid card action cluster ─────────────────────────────────────
Every action a row can surface — favorite star, `.file-actions`
kebab, per-section `.btn-action` icons (e.g. trash's Restore /
Delete permanently) — lives in a single `.action-cell` container
pinned to the top-right of the card. The container carries the
position + hover-reveal + gap; its children just supply their
own chip visuals (30x30 scrim pill, etc.), no more one-off
absolute positioning per child.
Old rules put `.file-actions` and `.favorite-star` at hand-crafted
absolute coordinates and hid `.btn-action` entirely — that made
trash's per-item buttons invisible in grid view. The unified
container reads as one design pattern and takes whatever children
the row template hands it. */
.files-grid-view .file-item .action-cell {
position: absolute;
top: calc(var(--space-3) + 8px);
right: calc(var(--space-3) + 8px);
z-index: 10;
display: flex;
gap: var(--space-1);
opacity: 0;
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-grid-view .file-item:hover .action-cell,
.files-grid-view .file-item:focus-within .action-cell,
.files-grid-view .file-item .action-cell:has(.favorite-star.active) {
opacity: 1;
}
/* The favorite state is already shown by the corner star button, so the inline
@@ -723,34 +753,37 @@
display: none;
}
.files-grid-view .file-item .file-actions {
position: absolute;
top: calc(var(--space-3) + 8px);
right: calc(var(--space-3) + 8px);
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */
.files-grid-view .file-item .action-cell .file-actions,
.files-grid-view .file-item .action-cell .favorite-star,
.files-grid-view .file-item .action-cell .btn-action {
position: static;
width: 30px;
height: 30px;
border-radius: var(--radius-full);
/* `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);
background: var(--color-scrim-control);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 1px 3px var(--color-shadow-sm);
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
z-index: 10;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-md);
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-grid-view .file-item:hover .file-actions {
cursor: pointer;
/* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */
opacity: 1;
}
.files-grid-view .file-item .file-actions:hover {
.files-grid-view .file-item .action-cell .file-actions:hover {
color: var(--color-accent);
}
@@ -782,34 +815,16 @@
line-height: var(--leading-none);
}
/* Favorite star — top-right of the thumbnail, left of the kebab, on a scrim. */
/* Favorite star — visual overrides only. Position, hover-reveal, chip
geometry all come from the shared corner-cluster rule on
`.files-grid-view .file-item .action-cell`. What's left here is just
the star's per-state colour: subtle at rest, active-gold when the
item is a favorite. `.active` still bumps the parent cluster's
opacity so an unhovered card can still show its star. */
.files-grid-view .file-item button.favorite-star {
position: absolute;
top: calc(var(--space-3) + 8px);
right: calc(var(--space-3) + 8px + 34px);
width: 30px;
height: 30px;
border-radius: var(--radius-full);
border: none;
background: var(--color-scrim-control);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 1px 3px var(--color-shadow-sm);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
z-index: 12;
cursor: pointer;
color: var(--color-text-subtle);
font-size: 15px;
padding: 0;
line-height: var(--leading-none);
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-grid-view .file-item:hover button.favorite-star {
opacity: 1;
}
.files-grid-view .file-item button.favorite-star:hover {
@@ -817,7 +832,6 @@
}
.files-grid-view .file-item button.favorite-star.active {
opacity: 1;
color: var(--color-star-text-hover);
}
@@ -1187,6 +1201,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);
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Folder-access cache — memoises "can the caller read this folder?" so
* UI decisions (e.g. showing / hiding the "Open parent folder" entry in
* a context menu) don't fire an HTTP call at click-time.
*
* The backend answers the question via `GET /api/folders/{id}`:
* * 2xx → caller has Read on the folder (or it's their own).
* * 404 → anti-enumeration; treated as "no access" from the UI's
* perspective (the recipient can't navigate there whether the
* folder exists or not).
*
* The cache is a simple insertion-order-bumping LRU capped at
* `MAX_ENTRIES`. `probeFolderAccess` is the async entry point; pages
* kick a bulk `warmFolderAccess` when a list loads so the cache is
* populated before the user right-clicks anything.
*/
import { getFolder } from '$lib/api/endpoints/folders';
const MAX_ENTRIES = 200;
// Cache: id → resolved answer. Presence means we know; `true`/`false`
// distinguishes the two outcomes. Insertion order preserved by Map;
// `bump` re-inserts on write so oldest sits at the front for eviction.
const cache = new Map<string, boolean>();
// In-flight dedup — if two callers ask about the same id before the
// first request settles, they share the same Promise. Cleared once the
// promise resolves.
const inflight = new Map<string, Promise<boolean>>();
function bump(id: string, value: boolean): void {
cache.delete(id);
cache.set(id, value);
// Trim from the front (oldest insertion) until we're back under cap.
while (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
}
/**
* Sync lookup — `undefined` means "not yet probed"; callers gating UI
* on this should call `warmFolderAccess` when items load so the
* `true` / `false` answer is present by the time the user reaches for
* the context menu.
*/
export function folderAccessCached(id: string): boolean | undefined {
return cache.get(id);
}
/**
* Async probe. Fires a `GET /api/folders/{id}` (deduplicated against
* concurrent callers) and caches the boolean outcome. Never throws —
* 404 and network failures both resolve to `false`.
*/
export async function probeFolderAccess(id: string): Promise<boolean> {
const cached = cache.get(id);
if (cached !== undefined) return cached;
const running = inflight.get(id);
if (running) return running;
const p = (async () => {
try {
await getFolder(id);
bump(id, true);
return true;
} catch {
bump(id, false);
return false;
} finally {
inflight.delete(id);
}
})();
inflight.set(id, p);
return p;
}
+67 -36
View File
@@ -28,6 +28,7 @@
type ItemContext
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
import { t } from '$lib/i18n/index.svelte';
let raw = $state<FavoritesResourceItem[]>([]);
@@ -223,7 +224,38 @@
a.remove();
}
// See /recent's mirror for the rationale: files carry `folder_id`,
// folders carry `parent_id`; nullable when the folder is a drive
// root. Null → no meaningful parent to open.
function parentFolderId(item: FileItem | FolderItem): string | null {
return isFile(item) ? item.folder_id : item.parent_id;
}
const contextActions: ContextAction[] = [
{
key: 'open_parent',
label: t('files.open_parent', 'Open parent folder'),
icon: 'folder-open',
// Hidden only when there's literally no parent to open
// (drive-root folders where `parent_id === null`); otherwise
// the entry is always visible and shows up disabled when the
// caller lacks read on the parent — a greyed row reads as
// "you can't do this here" instead of "the option is missing."
// `folderAccessCached` returns `true`/`false`/`undefined`;
// disabled fires when the answer is explicitly `false`. On
// first right-click of a fresh row, `menuPrepare` below has
// primed the cache so the entry either enables or disables
// without a "flash of enabled" beforehand.
visible: (item) => parentFolderId(item) !== null,
disabled: (item) => {
const pid = parentFolderId(item);
return pid === null || folderAccessCached(pid) === false;
},
run: (item) => {
const pid = parentFolderId(item);
if (pid) goto(resolve(`/files/${pid}`));
}
},
{
key: 'download',
label: t('common.download', 'Download'),
@@ -249,43 +281,33 @@
moveOpen = true;
}
},
{
// Every row on /favorites IS a favorite, so the entry is always
// "Remove favorite" — no per-item state lookup needed. Mirrors
// the star-widget behaviour: click, row un-stars, disappears
// from the list on next reload. Placed between Move and Rename
// to match the canonical context-menu order on `/files`.
key: 'unfavorite',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star-outline',
run: (item) => void unfavorite(item)
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
// Selected items arrive via the batchToolbar snippet param —
// Selected items arrive via the batchActions snippet param —
// ResourceList already derives them (O(selection), not O(N)); a
// host-side `items.filter(...)` shadow would re-run a second full scan
// per selection toggle, and its id mirror is unnecessary (the component
// prunes its own selection when items reload) — benches/ROUND11.md §S1.
type Selectable = FileItem | FolderItem;
function batchTargets(sel: Selectable[]) {
return sel.map((i) => ({ id: i.id, name: i.name, kind: kindOf(i) }));
}
function batchDownload(sel: Selectable[]) {
for (const i of sel) downloadItem(i);
}
async function batchDelete(sel: Selectable[]) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete_n', { count: sel.length }, 'Delete {{count}} item(s)?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(sel.map((i) => (isFile(i) ? deleteFile(i.id) : deleteFolder(i.id))));
const removed = new Set(sel.map((i) => i.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
} catch (e) {
errorToast(e);
}
}
onMount(() => load(true));
</script>
@@ -307,8 +329,18 @@
onopen={open}
onfavorite={unfavorite}
showOwner
showPath
dateLabel={t('files.col_added', 'Added')}
selectable
{contextActions}
menuPrepare={async (item) => {
// Lazy folder-access probe — fires only when the user actually
// opens the context menu on a row, not proactively for every
// row on load. Cached in the LRU (see `folderAccess.ts`) so
// subsequent right-clicks on the same folder are instant.
const pid = parentFolderId(item);
if (pid) await probeFolderAccess(pid);
}}
{groupBys}
bind:groupBy
bind:reversed
@@ -317,26 +349,25 @@
load(true, orderBy, rev);
}}
>
{#snippet batchToolbar(sel)}
{#snippet batchActions(sel)}
<!--
Favorites-scoped batch cluster: Download stays. Move + Delete
were destructive-to-content operations carried over from the
pre-refactor menu; on a favorites *bookmarks* view they
belong in the row's context menu (rename/move/delete via
`contextActions`), not in the batch bar. Batch "remove from
favorite" un-stars the selected rows without touching the
underlying files — mirrors the per-row favorite star.
-->
<Button
icon="download"
data-testid="favorites-batch-download-btn"
onclick={() => batchDownload(sel)}>{t('common.download', 'Download')}</Button
>
<Button
icon="arrows-alt"
data-testid="favorites-batch-move-btn"
onclick={() => {
moveTarget = null;
moveItems = batchTargets(sel);
moveOpen = true;
}}>{t('files.move', 'Move')}</Button
>
<Button
variant="danger"
icon="trash"
data-testid="favorites-batch-delete-btn"
onclick={() => batchDelete(sel)}>{t('common.delete', 'Delete')}</Button
icon="star-outline"
data-testid="favorites-batch-remove-btn"
onclick={() => sel.forEach(unfavorite)}>{t('files.unfavorite', 'Remove favorite')}</Button
>
{/snippet}
</ResourceList>
+10 -6
View File
@@ -26,7 +26,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import FavoritesPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
@@ -86,13 +85,18 @@ it('unfavorites a row via the star button', async () => {
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
});
it('batch-deletes selected favorites after confirmation', async () => {
it('batch-removes-from-favorite the selection', async () => {
// /favorites' batch bar was intentionally trimmed to Download +
// Remove-from-favorite. Bulk-deleting the underlying file from
// this view (previous behaviour) confused the "this is a
// bookmarks list" semantics — destructive actions belong in the
// row's context menu, not in the batch bar. This test pins the
// new shape: batch button just un-stars the selection.
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
m(removeFavorite).mockResolvedValue(undefined);
render(FavoritesPage);
await screen.findByText('photo.png');
await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox'));
await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
await fireEvent.click(await screen.findByTestId('favorites-batch-remove-btn'));
await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1'));
});
File diff suppressed because it is too large Load Diff
+19 -28
View File
@@ -46,12 +46,10 @@ vi.mock('$lib/api/endpoints/files', () => ({
uploadFileWithProgress: vi.fn()
}));
vi.mock('$lib/api/endpoints/folders', () => ({
cacheFolder: vi.fn(),
createFolder: vi.fn(),
deleteFolder: vi.fn(),
fetchFolderListing: vi.fn(),
fetchFolderPage: vi.fn(),
folderZipUrl: () => '/zip',
getCachedFolder: () => undefined,
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
getFolderName: () => undefined,
invalidateFolderCache: vi.fn(),
@@ -60,7 +58,7 @@ vi.mock('$lib/api/endpoints/folders', () => ({
renameFolder: vi.fn()
}));
import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
import { deleteFile } from '$lib/api/endpoints/files';
import { apiFetch } from '$lib/api/client';
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -69,15 +67,17 @@ import FilesPage from './[...path]/+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function withListing() {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
// `fetchFolderPage` returns ONE page with the accumulator shape (items in
// server order + folders/files splits). With `nextCursor` omitted the
// caller treats it as the last page — the page's items become the whole
// on-screen listing without triggering `loadMore`.
const folder = folderItem('sub1', 'Sub');
const file = fileItem('f1', 'hello.txt');
m(fetchFolderPage).mockResolvedValue({
items: [folder, file],
folders: [folder],
files: [file],
nextCursor: undefined
});
}
@@ -131,27 +131,18 @@ beforeEach(() => {
});
it('loads the home folder listing on mount and renders its contents', async () => {
m(fetchFolderListing).mockResolvedValue({
status: 200,
etag: 'v1',
listing: {
folders: [folderItem('sub1', 'Sub')],
files: [fileItem('f1', 'hello.txt')],
favoriteIds: [],
sharedIds: []
}
});
withListing();
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything()));
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledWith('home', expect.anything()));
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
// surrounding chrome rendered rather than the windowed rows themselves.
await screen.findByTestId('files-new-folder-btn');
});
it('shows an error when the listing fails with no cache', async () => {
m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
render(FilesPage);
await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled());
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
});
it('redirects external users away from the home folder', async () => {
@@ -177,7 +168,7 @@ it('batch-deletes the whole selection after confirmation', async () => {
m(deleteFolder).mockResolvedValue(undefined);
m(deleteFile).mockResolvedValue(undefined);
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
@@ -187,7 +178,7 @@ it('batch-favorites the selection via the favorites batch endpoint', async () =>
withListing();
m(apiFetch).mockResolvedValue({ ok: true });
render(FilesPage);
await fireEvent.click(await screen.findByTestId('files-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith(
+134 -82
View File
@@ -5,14 +5,17 @@
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 {
clearRecent,
fetchRecentPage,
removeFromRecent,
type RecentResourceItem
} from '$lib/api/endpoints/recent';
import {
addFavorite,
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
@@ -31,12 +34,11 @@
// `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 { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
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,13 +47,13 @@
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
// from Favorites'/Files' `created_by`).
// Envelope shape: `accessed_at` → `ctx.date`, `created_by` → `ctx.ownerId`.
// Recent is a per-user view of items the caller accessed; the "who
// touched this last" (`updated_by`) semantic is real but adds noise
// (mostly the current user), so we align with Files / Favorites and
// show the original author instead. Cross-surface consistency wins
// over the finer-grained signal.
//
// Dotfile hiding is delegated to ResourceList via `showDotfileToggle`
// — the component reads `preferences.hideDotfiles` and drops matching
@@ -62,7 +64,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 +106,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;
@@ -130,10 +120,10 @@
raw = reset ? page.items : [...raw, ...page.items];
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null }
{ date: it.accessed_at, ownerId: it.resource.created_by ?? null }
]);
cursor = page.next_cursor;
void owners.resolve(page.items.map((i) => i.resource.updated_by));
void owners.resolve(page.items.map((i) => i.resource.created_by));
} catch (e) {
console.error('recent: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
@@ -173,24 +163,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.created_by ?? null
});
errorToast(e);
}
}
@@ -261,7 +259,35 @@
a.remove();
}
// Extract the parent-folder id from any item — files carry `folder_id`
// (required by the DTO), folders carry `parent_id` (nullable when the
// folder is a drive root). `null` means "no meaningful parent to open";
// the "Open parent folder" entry stays hidden in that case.
function parentFolderId(item: FileItem | FolderItem): string | null {
return isFile(item) ? item.folder_id : item.parent_id;
}
const contextActions: ContextAction[] = [
{
key: 'open_parent',
label: t('files.open_parent', 'Open parent folder'),
icon: 'folder-open',
// Same disabled-not-hidden pattern as /favorites: hide only
// when there's no parent (drive-root folder), otherwise
// show and disable when the caller lacks Read on the
// parent. `menuPrepare` primes the cache before the menu
// renders so the final enabled/disabled state is correct
// on the very first right-click of a row.
visible: (item) => parentFolderId(item) !== null,
disabled: (item) => {
const pid = parentFolderId(item);
return pid === null || folderAccessCached(pid) === false;
},
run: (item) => {
const pid = parentFolderId(item);
if (pid) goto(resolve(`/files/${pid}`));
}
},
{
key: 'download',
label: t('common.download', 'Download'),
@@ -287,45 +313,38 @@
moveOpen = true;
}
},
{
// "Add to favorites" — /recent doesn't track per-row favorite
// state (the star widget was replaced by the broom), so the
// entry always reads "Add" and the backend swallows duplicate
// adds idempotently. If the user wants to un-favorite, they
// navigate to /favorites and use the row menu there. Placed
// between Move and Rename to match the canonical context-menu
// order on `/files`.
key: 'favorite',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(kindOf(item), item.id).catch(errorToast);
}
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
// Selected items arrive via the batchToolbar snippet param —
// Selected items arrive via the batchActions snippet param —
// ResourceList already derives them (O(selection), not O(N)); a
// host-side `items.filter(...)` shadow would re-run a second full scan
// per selection toggle, and its id mirror is unnecessary (the component
// prunes its own selection when items reload) — benches/ROUND11.md §S1.
type Selectable = FileItem | FolderItem;
function batchTargets(sel: Selectable[]) {
return sel.map((i) => ({ id: i.id, name: i.name, kind: kindOf(i) }));
}
function batchDownload(sel: Selectable[]) {
for (const i of sel) downloadItem(i);
}
async function batchDelete(sel: Selectable[]) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete_n', { count: sel.length }, 'Delete {{count}} item(s)?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(sel.map((i) => (isFile(i) ? deleteFile(i.id) : deleteFolder(i.id))));
const removed = new Set(sel.map((i) => i.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
} catch (e) {
errorToast(e);
}
}
onMount(() => {
void loadFavoriteIds();
void load(true);
});
</script>
@@ -336,7 +355,6 @@
title={t('nav.recent', 'Recent')}
{items}
{contextMap}
{favoriteIds}
resolveOwnerName={(id) => owners.name(id)}
{loading}
{error}
@@ -354,11 +372,20 @@
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
showOwner
showPath
dateLabel={t('files.col_opened', 'Opened')}
showDotfileToggle
selectable
{contextActions}
menuPrepare={async (item) => {
// Lazy folder-access probe — fires only when the user actually
// opens the context menu on a row, not proactively for every
// row on load. Cached in the LRU forever after (per-session);
// subsequent right-clicks on the same folder are instant.
const pid = parentFolderId(item);
if (pid) await probeFolderAccess(pid);
}}
{groupBys}
bind:groupBy
bind:reversed
@@ -367,34 +394,59 @@
load(true, orderBy, rev);
}}
>
{#snippet toolbar()}
{#snippet actions()}
{#if items.length > 0}
<Button icon="broom" data-testid="recent-clear-btn" onclick={clearAll}
>{t('recent.clear', 'Clear recent')}</Button
>
{/if}
{/snippet}
{#snippet batchToolbar(sel)}
{#snippet batchActions(sel)}
<!--
Recent-scoped batch cluster: what makes sense on a HISTORY
view. Download stays (common bulk fetch). Move + Delete
were destructive-to-content actions carried over from the
pre-refactor menu; on a history view they belong in the
row's context menu (rename/move/delete via `contextActions`
above), not in the batch bar. Batch "remove from recent"
mirrors the per-row broom and forgets the selected rows
from history without touching the files themselves.
-->
<Button
icon="download"
data-testid="recent-batch-download-btn"
onclick={() => batchDownload(sel)}>{t('common.download', 'Download')}</Button
>
<Button
icon="arrows-alt"
data-testid="recent-batch-move-btn"
onclick={() => {
moveTarget = null;
moveItems = batchTargets(sel);
moveOpen = true;
}}>{t('files.move', 'Move')}</Button
icon="broom"
data-testid="recent-batch-remove-btn"
onclick={() => sel.forEach(removeItem)}
>{t('recent.remove_item', 'Remove from recent')}</Button
>
<Button
variant="danger"
icon="trash"
data-testid="recent-batch-delete-btn"
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>
+24 -16
View File
@@ -5,12 +5,13 @@ const { confirmDialog, promptDialog } = vi.hoisted(() => ({
confirmDialog: vi.fn(),
promptDialog: vi.fn()
}));
vi.mock('$lib/api/endpoints/recent', () => ({ clearRecent: vi.fn(), fetchRecentPage: vi.fn() }));
vi.mock('$lib/api/endpoints/recent', () => ({
clearRecent: vi.fn(),
fetchRecentPage: vi.fn(),
removeFromRecent: vi.fn()
}));
vi.mock('$lib/api/endpoints/favorites', () => ({
addFavorite: vi.fn(),
dateBucket: () => 'Today',
fetchFavoritesPage: vi.fn(async () => ({ items: [], next_cursor: null })),
removeFavorite: vi.fn(),
resolveOwnerName: vi.fn(async () => 'me'),
sizeBucket: () => 'Small',
typeLabel: () => 'File'
@@ -23,9 +24,7 @@ vi.mock('$lib/api/endpoints/files', () => ({
vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFolder: vi.fn() }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
import { fetchRecentPage, clearRecent } from '$lib/api/endpoints/recent';
import { addFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile } from '$lib/api/endpoints/files';
import { fetchRecentPage, clearRecent, removeFromRecent } from '$lib/api/endpoints/recent';
import RecentPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
@@ -79,24 +78,33 @@ it('clears recent activity after confirmation', async () => {
await waitFor(() => expect(clearRecent).toHaveBeenCalled());
});
it('favorites a recent row via the star button', async () => {
it('removes a recent row via the broom button', async () => {
// Recent no longer surfaces a favorite star (users go to the item's
// real home for that). The per-row affordance is now a broom that
// calls `DELETE /api/recent/{kind}/{id}` — verified end-to-end via
// the `removeFromRecent` mock.
withOneFile();
m(addFavorite).mockResolvedValue(undefined);
m(removeFromRecent).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-favorite-r1-btn'));
await waitFor(() => expect(addFavorite).toHaveBeenCalledWith('file', 'r1'));
await fireEvent.click(screen.getByTestId('recent-remove-btn-r1'));
await waitFor(() => expect(removeFromRecent).toHaveBeenCalledWith('file', 'r1'));
});
it('batch-deletes selected recent items after confirmation', async () => {
it('batch-removes-from-recent the selection', async () => {
// /recent's batch bar was intentionally trimmed to Download +
// Remove-from-recent. Bulk-deleting the underlying file from
// this history view (previous behaviour) confused the "this is
// activity log" semantics — destructive actions belong in the
// row's context menu, not in the batch bar. This test pins the
// new shape: batch button just forgets the selection from history.
withOneFile();
confirmDialog.mockResolvedValue(true);
m(deleteFile).mockResolvedValue(undefined);
m(removeFromRecent).mockResolvedValue(undefined);
render(RecentPage);
await screen.findByText('notes.txt');
await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox'));
await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn'));
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1'));
await fireEvent.click(await screen.findByTestId('recent-batch-remove-btn'));
await waitFor(() => expect(removeFromRecent).toHaveBeenCalledWith('file', 'r1'));
});
it('renders an empty state when there is no recent activity', async () => {
@@ -1,17 +1,25 @@
<script lang="ts">
import { errorMessage } from '$lib/utils/errors';
import { errorMessage, errorToast } from '$lib/utils/errors';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites';
import {
addFavorite,
dateBucket,
resolveOwnerName,
typeLabel
} from '$lib/api/endpoints/favorites';
import { fileDownloadUrl } from '$lib/api/endpoints/files';
import { folderZipUrl } from '$lib/api/endpoints/folders';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import type { FileItem, FolderItem } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
import ResourceList, {
isFile,
type ContextAction,
type GroupByDef,
type ItemContext
} from '$lib/components/ResourceList.svelte';
@@ -130,6 +138,59 @@
viewerOpen = true;
}
/**
* Kick off a download using an ephemeral `<a download>` so the file
* saves to disk instead of navigating away. Files stream directly
* from `/api/files/{id}/content`; folders come back as a server-
* side zip via `/api/folders/{id}/zip`.
*/
function downloadItem(item: FileItem | FolderItem) {
const a = document.createElement('a');
a.href = isFile(item) ? fileDownloadUrl(item.id) : folderZipUrl(item.id);
a.download = isFile(item) ? item.name : `${item.name}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
}
// Context menu ordering mirrors `/files` / `/favorites` / `/recent`:
// Download / Download as ZIP → (later entries as we grow the menu)
// Favorite → (destructive actions if / when introduced)
//
// Kind-gated download: files show "Download" (direct stream);
// folders show "Download as ZIP" (server-side archive). Two entries
// with `visible?` predicates rather than one label that changes,
// so the `.icon` reads correctly per kind too.
//
// The favorite entry stays "Add to favorites" only: un-favoriting
// from here would need per-row favorite-state tracking which this
// view doesn't carry — users toggle off from /favorites' own row
// menu. Backend swallows duplicate `addFavorite` calls idempotently.
const contextActions: ContextAction[] = [
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
visible: (item) => isFile(item),
run: downloadItem
},
{
key: 'download_zip',
label: t('files.download_zip', 'Download as ZIP'),
icon: 'download',
visible: (item) => !isFile(item),
run: downloadItem
},
{
key: 'favorite',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast);
}
}
];
onMount(() => load(true));
</script>
@@ -161,11 +222,14 @@
{items}
{contextMap}
resolveOwnerName={(id) => sharers.name(id)}
{contextActions}
{loading}
{error}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
hasMore={!!cursor}
showOwner={true}
ownerLabel={t('share.col_shared_by', 'Shared by')}
dateLabel={t('share.col_shared', 'Shared')}
{groupBys}
bind:groupBy
bind:reversed
+54 -4
View File
@@ -13,8 +13,10 @@
restoreTrashItem
} from '$lib/api/endpoints/trash';
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
import { formatDate } from '$lib/utils/display';
import type { Drive, FileItem, FolderItem, TrashResourceItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Button from '$lib/components/Button.svelte';
import ResourceList, {
isFile,
type GroupByDef,
@@ -268,8 +270,10 @@
emptyText={t('trash.empty_state', 'Trash is empty')}
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
selectable
showPath
pathLabel={t('trash.original_location', 'Original location')}
dateLabel={t('trash.remaining', 'Remaining')}
dateLabel={t('trash.expires_at', 'Expires at')}
{groupBys}
bind:groupBy
bind:reversed
@@ -278,7 +282,7 @@
load(true, orderBy, rev);
}}
>
{#snippet toolbar()}
{#snippet actions()}
{#if items.length > 0}
<button class="btn btn-danger" data-testid="trash-empty-btn" onclick={purgeAll}>
<Icon name="trash" />
@@ -286,13 +290,36 @@
</button>
{/if}
{/snippet}
{#snippet dateCell(_item, ctx)}
{#snippet batchActions(sel)}
<!--
Use the shared `<Button>` component here (not the icon-only
`.btn-action` chip used for per-row `itemActions` above). The
batch bar renders text next to the glyph — `.btn-action` is
fixed 28x28 with no room for a label, and shoving text
inside was overlapping the icon. `<Button>` picks up the
standard action-bar sizing and reads consistently with
`/recent` and `/favorites` batch clusters.
-->
<Button icon="undo" data-testid="trash-batch-restore-btn" onclick={() => sel.forEach(restore)}
>{t('trash.restore', 'Restore')}</Button
>
<Button
variant="danger"
icon="trash"
data-testid="trash-batch-delete-btn"
onclick={() => sel.forEach(purge)}>{t('trash.delete', 'Delete permanently')}</Button
>
{/snippet}
{#snippet rowBadge(_item, ctx)}
{@const chip = expiryChip(ctx?.date)}
<span class="expiry-chip expiry-chip--{chip.tier}">
<Icon name={chip.icon} class="expiry-chip__icon" />
{chip.label}
</span>
{/snippet}
{#snippet dateCell(_item, ctx)}
{formatDate(ctx?.date)}
{/snippet}
{#snippet bucketAction(bucketKey: string)}
{#if showPerDriveEmpty}
{@const driveId = driveIdFromBucketKey(bucketKey)}
@@ -310,7 +337,7 @@
{/if}
{/if}
{/snippet}
{#snippet actions(item)}
{#snippet itemActions(item)}
<button
class="btn-action"
data-testid={`trash-restore-btn-${item.id}`}
@@ -374,4 +401,27 @@
color: var(--color-danger-text);
font-weight: var(--weight-semibold);
}
/* Grid-corner action-cell layout + chip visuals now live in the
shared `ported/resourceList.css`; every section using ResourceList
picks them up. What stays here is only the trash-specific danger
red on the "Delete permanently" button — `--color-error-text` is
the right red-text token (the shared `.file-actions:hover` accent
colour still lands on the plain `.btn-action` restore button). */
:global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) {
color: var(--color-error-text);
}
/* List view: hide the expiry chip that ResourceList paints inside
`.file-icon__badge`. In list mode the same info is already in
the "Expires at" column (`dateCell` snippet above) — showing
the chip on the tiny row icon crops it and duplicates the
signal. Grid view keeps the chip: no dedicated column exists
there and the badge is the ONLY expiration surface on the
card. Scoped to trash because trash is the only section
emitting a rowBadge today; if another section starts using it,
this rule stays inert for them. */
:global(.files-list-view .file-item .file-icon__badge) {
display: none;
}
</style>
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "إشعار عبر البريد الإلكتروني",
"revoke": "Remove",
"role_label": "الدور"
"role_label": "الدور",
"col_shared_by": "شورك بواسطة",
"col_shared": "مشترك"
},
"share_dialogTitle": "رابط المشاركة",
"share_linkLabel": "رابط المشاركة:",
@@ -337,6 +339,7 @@
"modified": "تاريخ التعديل",
"no_files": "لا توجد ملفات في هذا المجلد",
"empty_hint": "ارفع ملفات أو أنشئ مجلدات للبدء",
"drop_to_upload": "أفلت الملفات هنا للرفع",
"loading": "جارٍ تحميل الملفات…",
"view_grid": "عرض شبكي",
"view_list": "عرض قائمة",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "تمت إعادة التسمية إلى \"{{name}}\" — أصبحت الآن مخفية وفقاً لتفضيلاتك.",
"new_folder_dotfile_hidden": "تم إنشاء المجلد \"{{name}}\" — مخفي وفقاً لتفضيلاتك.",
"dotfiles_hidden_toast": "تم إخفاء الملفات المخفية",
"dotfiles_shown_toast": "تم إظهار الملفات المخفية"
"dotfiles_shown_toast": "تم إظهار الملفات المخفية",
"col_modified": "معدل",
"col_added": "أضيف",
"col_created_by": "أنشئ بواسطة",
"col_opened": "افتُح",
"col_path": "الموقع",
"new_elements": "عناصر جديدة"
},
"dialogs": {
"rename_folder": "إعادة تسمية المجلد",
@@ -461,7 +470,8 @@
"trashed_time": "وقت الحذف"
},
"delete": "حذف نهائياً",
"empty_action": "تفريغ سلة المهملات"
"empty_action": "تفريغ سلة المهملات",
"expires_at": "ينتهي في"
},
"daysRemaining": {
"expired": "منتهية الصلاحية",
@@ -582,7 +592,8 @@
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
"empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك",
"empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.",
"loadMore": "تحميل المزيد"
"loadMore": "تحميل المزيد",
"remove_item": "إزالة من الأخيرة"
},
"notifications": {
"file_renamed": "تمت إعادة تسمية الملف",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "لا يوجد أعضاء",
"member_count": "{{n}} أعضاء"
},
"resource_list": {
"location": "الموقع",
"wrong_drop_zone_msg": "الرفع يعمل فقط في قسم الملفات — افتح قسم الملفات وأفلت العناصر هناك.",
"wrong_drop_zone_action": "انتقل إلى الملفات"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Per E-Mail benachrichtigen",
"revoke": "Remove",
"role_label": "Rolle"
"role_label": "Rolle",
"col_shared_by": "Geteilt von",
"col_shared": "Geteilt"
},
"share_dialogTitle": "Link teilen",
"share_linkLabel": "Geteilter Link:",
@@ -337,6 +339,7 @@
"modified": "Geändert",
"no_files": "Keine Dateien in diesem Ordner",
"empty_hint": "Laden Sie Dateien hoch oder erstellen Sie Ordner, um loszulegen",
"drop_to_upload": "Dateien zum Hochladen hier ablegen",
"loading": "Dateien werden geladen…",
"view_grid": "Rasteransicht",
"view_list": "Listenansicht",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "In \"{{name}}\" umbenannt — jetzt durch Ihre Einstellung ausgeblendet.",
"new_folder_dotfile_hidden": "Ordner \"{{name}}\" erstellt — durch Ihre Einstellung ausgeblendet.",
"dotfiles_hidden_toast": "Verborgene Dateien ausgeblendet",
"dotfiles_shown_toast": "Verborgene Dateien angezeigt"
"dotfiles_shown_toast": "Verborgene Dateien angezeigt",
"col_modified": "Geändert",
"col_added": "Hinzugefügt",
"col_created_by": "Erstellt von",
"col_opened": "Geöffnet",
"col_path": "Speicherort",
"new_elements": "Neue Elemente"
},
"dialogs": {
"rename_folder": "Ordner umbenennen",
@@ -461,7 +470,8 @@
"trashed_time": "Löschzeit"
},
"delete": "Endgültig löschen",
"empty_action": "Papierkorb leeren"
"empty_action": "Papierkorb leeren",
"expires_at": "Läuft ab"
},
"daysRemaining": {
"expired": "Abgelaufen",
@@ -582,7 +592,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",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Keine Mitglieder",
"member_count": "{{n}} Mitglieder"
},
"resource_list": {
"location": "Speicherort",
"wrong_drop_zone_msg": "Uploads funktionieren nur in Dateien — öffne den Bereich Dateien und lege die Elemente dort ab.",
"wrong_drop_zone_action": "Zu Dateien wechseln"
}
}
+18 -4
View File
@@ -339,7 +339,9 @@
"set_expiry": "Set expiry",
"title": "Shared",
"unlock": "Unlock",
"role_label": "Role"
"role_label": "Role",
"col_shared_by": "Shared by",
"col_shared": "Shared"
},
"share_dialogTitle": "Share Link",
"share_linkLabel": "Share Link:",
@@ -442,6 +444,7 @@
"modified": "Modified",
"no_files": "No files in this folder",
"empty_hint": "Upload files or create folders to get started",
"drop_to_upload": "Drop files here to upload",
"loading": "Loading files…",
"view_grid": "Grid view",
"view_list": "List view",
@@ -466,7 +469,10 @@
"batch_delete": "Delete selected",
"breadcrumb": "Breadcrumb",
"cancel_selection": "Cancel selection",
"col_modified": "Date",
"col_modified": "Modified",
"col_added": "Added",
"col_created_by": "Created by",
"col_opened": "Opened",
"col_name": "Name",
"col_owner": "Owner",
"col_path": "Location",
@@ -499,6 +505,7 @@
"moved": "Moved",
"new_folder": "New folder",
"new_folder_prompt": "New folder name",
"new_elements": "New elements",
"no_home": "No home folder available.",
"no_preview": "No preview available for this file type.",
"no_subfolders": "No subfolders here.",
@@ -618,7 +625,8 @@
"confirm_empty": "Empty the trash? This cannot be undone.",
"delete": "Delete permanently",
"empty_action": "Empty trash",
"restored": "Restored"
"restored": "Restored",
"expires_at": "Expires at"
},
"daysRemaining": {
"expired": "Expired",
@@ -753,7 +761,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",
@@ -1663,5 +1672,10 @@
"group": {
"members_empty": "No members",
"member_count": "{{n}} members"
},
"resource_list": {
"location": "Location",
"wrong_drop_zone_msg": "Uploads only work in Files — open the Files section and drop there.",
"wrong_drop_zone_action": "Go to Files"
}
}
+20 -4
View File
@@ -183,7 +183,9 @@
"link_name": "Nombre del enlace (opcional)",
"notifyByEmail": "Notificar por correo",
"revoke": "Eliminar",
"role_label": "Rol"
"role_label": "Rol",
"col_shared_by": "Compartido por",
"col_shared": "Compartido"
},
"share_dialogTitle": "Compartir Enlace",
"share_linkLabel": "Enlace compartido:",
@@ -337,6 +339,7 @@
"modified": "Modificado",
"no_files": "No hay archivos en esta carpeta",
"empty_hint": "Sube archivos o crea carpetas para comenzar",
"drop_to_upload": "Arrastra archivos aquí para subirlos",
"loading": "Cargando archivos…",
"view_grid": "Vista de cuadrícula",
"view_list": "Vista de lista",
@@ -380,7 +383,13 @@
"rename_dotfile_hidden": "Renombrado a \"{{name}}\" — ahora oculto por tu preferencia.",
"new_folder_dotfile_hidden": "Carpeta \"{{name}}\" creada — oculta por tu preferencia.",
"dotfiles_hidden_toast": "Archivos ocultos ocultados",
"dotfiles_shown_toast": "Archivos ocultos mostrados"
"dotfiles_shown_toast": "Archivos ocultos mostrados",
"col_modified": "Modificado",
"col_added": "Añadido",
"col_created_by": "Creado por",
"col_opened": "Abierto",
"col_path": "Ubicación",
"new_elements": "Nuevos elementos"
},
"dialogs": {
"rename_folder": "Renombrar carpeta",
@@ -466,7 +475,8 @@
"trashed_time": "Fecha de eliminación"
},
"delete": "Eliminar permanentemente",
"empty_action": "Vaciar papelera"
"empty_action": "Vaciar papelera",
"expires_at": "Expira"
},
"daysRemaining": {
"expired": "Caducado",
@@ -587,7 +597,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",
@@ -1280,5 +1291,10 @@
"group": {
"members_empty": "Sin miembros",
"member_count": "{{n}} miembros"
},
"resource_list": {
"location": "Ubicación",
"wrong_drop_zone_msg": "Las subidas solo funcionan en Archivos — abre la sección Archivos y suelta ahí los elementos.",
"wrong_drop_zone_action": "Ir a Archivos"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "اطلاع‌رسانی از طریق ایمیل",
"revoke": "Remove",
"role_label": "نقش"
"role_label": "نقش",
"col_shared_by": "به اشتراک گذاشته شده توسط",
"col_shared": "به اشتراک گذاشته شده"
},
"share_dialogTitle": "پیوند هم‌رسانی",
"share_linkLabel": "پیوند هم‌رسانی:",
@@ -337,6 +339,7 @@
"modified": "تاریخ تغییر",
"no_files": "هنوز هیچ پرونده‌ای در این پوشه وجود ندارد",
"empty_hint": "برای شروع، فایل‌ها را آپلود کنید یا پوشه بسازید",
"drop_to_upload": "برای بارگذاری، فایل‌ها را اینجا رها کنید",
"loading": "در حال بارگذاری فایل‌ها…",
"view_grid": "نمای شبکه‌ای",
"view_list": "نمای فهرستی",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "نام به \"{{name}}\" تغییر کرد — اکنون طبق تنظیمات شما پنهان است.",
"new_folder_dotfile_hidden": "پوشه \"{{name}}\" ایجاد شد — طبق تنظیمات شما پنهان است.",
"dotfiles_hidden_toast": "پرونده‌های پنهان مخفی شد",
"dotfiles_shown_toast": "پرونده‌های پنهان نمایش داده شد"
"dotfiles_shown_toast": "پرونده‌های پنهان نمایش داده شد",
"col_modified": "تغییر یافته",
"col_added": "افزوده شده",
"col_created_by": "ایجاد شده توسط",
"col_opened": "باز شده",
"col_path": "مکان",
"new_elements": "موارد جدید"
},
"dialogs": {
"rename_folder": "تغییر نام پوشه",
@@ -461,7 +470,8 @@
"trashed_time": "زمان حذف"
},
"delete": "حذف دائمی",
"empty_action": "Empty trash"
"empty_action": "Empty trash",
"expires_at": "انقضا در"
},
"daysRemaining": {
"expired": "منقضی شده",
@@ -582,7 +592,8 @@
"empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند",
"empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است",
"empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.",
"loadMore": "بارگذاری بیشتر"
"loadMore": "بارگذاری بیشتر",
"remove_item": "حذف از اخیر"
},
"batch": {
"one_selected": "۱ مورد انتخاب شده",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "بدون عضو",
"member_count": "{{n}} عضو"
},
"resource_list": {
"location": "مکان",
"wrong_drop_zone_msg": "بارگذاری فقط در بخش پرونده‌ها کار می‌کند — بخش پرونده‌ها را باز کنید و آنجا رها کنید.",
"wrong_drop_zone_action": "برو به پرونده‌ها"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Notifier par e-mail",
"revoke": "Remove",
"role_label": "Rôle"
"role_label": "Rôle",
"col_shared_by": "Partagé par",
"col_shared": "Partagé"
},
"share_dialogTitle": "Lien de partage",
"share_linkLabel": "Lien partagé :",
@@ -337,6 +339,7 @@
"modified": "Modifié",
"no_files": "Aucun fichier dans ce dossier",
"empty_hint": "Téléversez des fichiers ou créez des dossiers pour commencer",
"drop_to_upload": "Déposez les fichiers ici pour les téléverser",
"loading": "Chargement des fichiers…",
"view_grid": "Vue en grille",
"view_list": "Vue en liste",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.",
"new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.",
"dotfiles_hidden_toast": "Fichiers masqués",
"dotfiles_shown_toast": "Fichiers affichés"
"dotfiles_shown_toast": "Fichiers affichés",
"col_modified": "Modifié",
"col_added": "Ajouté",
"col_created_by": "Créé par",
"col_opened": "Ouvert",
"col_path": "Emplacement",
"new_elements": "Nouveaux éléments"
},
"dialogs": {
"rename_folder": "Renommer le dossier",
@@ -461,7 +470,8 @@
"trashed_time": "Date de suppression"
},
"delete": "Supprimer définitivement",
"empty_action": "Vider la corbeille"
"empty_action": "Vider la corbeille",
"expires_at": "Expiration"
},
"daysRemaining": {
"expired": "Expiré",
@@ -582,7 +592,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é",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Aucun membre",
"member_count": "{{n}} membres"
},
"resource_list": {
"location": "Emplacement",
"wrong_drop_zone_msg": "Les envois ne fonctionnent que dans Fichiers — ouvrez la section Fichiers et déposez-y vos éléments.",
"wrong_drop_zone_action": "Aller aux Fichiers"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "ईमेल से सूचित करें",
"revoke": "Remove",
"role_label": "भूमिका"
"role_label": "भूमिका",
"col_shared_by": "द्वारा साझा किया गया",
"col_shared": "साझा किया गया"
},
"share_dialogTitle": "शेयर लिंक",
"share_linkLabel": "शेयर लिंक:",
@@ -337,6 +339,7 @@
"modified": "संशोधित",
"no_files": "इस फ़ोल्डर में कोई फ़ाइल नहीं",
"empty_hint": "शुरू करने के लिए ह़ैलें अपलोड करें या होल्डर बनाएँ",
"drop_to_upload": "अपलोड करने के लिए फ़ाइलें यहाँ छोड़ें",
"loading": "फ़ाइलें लोड हो रही हैं…",
"view_grid": "ग्रिड दृश्य",
"view_list": "सूची दृश्य",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "\"{{name}}\" में नाम बदला — अब आपकी वरीयता के अनुसार छिपा हुआ है।",
"new_folder_dotfile_hidden": "फ़ोल्डर \"{{name}}\" बनाया गया — आपकी वरीयता के अनुसार छिपा हुआ है।",
"dotfiles_hidden_toast": "छिपी फ़ाइलें छिपाई गईं",
"dotfiles_shown_toast": "छिपी फ़ाइलें दिखाई गईं"
"dotfiles_shown_toast": "छिपी फ़ाइलें दिखाई गईं",
"col_modified": "संशोधित",
"col_added": "जोड़ा गया",
"col_created_by": "द्वारा बनाया गया",
"col_opened": "खोला गया",
"col_path": "स्थान",
"new_elements": "नए तत्व"
},
"dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें",
@@ -461,7 +470,8 @@
"trashed_time": "हटाने का समय"
},
"delete": "स्थायी रूप से हटाएँ",
"empty_action": "रद्दी खाली करें"
"empty_action": "रद्दी खाली करें",
"expires_at": "समाप्ति"
},
"daysRemaining": {
"expired": "समाप्त",
@@ -582,7 +592,8 @@
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
"empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं",
"empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।",
"loadMore": "और लोड करें"
"loadMore": "और लोड करें",
"remove_item": "हाल के से हटाएँ"
},
"notifications": {
"file_renamed": "फ़ाइल का नाम बदला गया",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "कोई सदस्य नहीं",
"member_count": "{{n}} सदस्य"
},
"resource_list": {
"location": "स्थान",
"wrong_drop_zone_msg": "अपलोड केवल फ़ाइलें अनुभाग में काम करता है — फ़ाइलें अनुभाग खोलें और वहीं छोड़ें।",
"wrong_drop_zone_action": "फ़ाइलों पर जाएँ"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Notifica via email",
"revoke": "Remuovi",
"role_label": "Ruolo"
"role_label": "Ruolo",
"col_shared_by": "Condiviso da",
"col_shared": "Condiviso"
},
"share_dialogTitle": "Link di condivisione",
"share_linkLabel": "Link di condivisione:",
@@ -337,6 +339,7 @@
"modified": "Modificato",
"no_files": "Nessun file in questa cartella",
"empty_hint": "Carica file o crea cartelle per iniziare",
"drop_to_upload": "Trascina i file qui per caricarli",
"loading": "Caricamento file…",
"view_grid": "Visualizzazione griglia",
"view_list": "Visualizzazione elenco",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Rinominato in \"{{name}}\" — ora nascosto dalla tua preferenza.",
"new_folder_dotfile_hidden": "Cartella \"{{name}}\" creata — nascosta dalla tua preferenza.",
"dotfiles_hidden_toast": "File nascosti occultati",
"dotfiles_shown_toast": "File nascosti mostrati"
"dotfiles_shown_toast": "File nascosti mostrati",
"col_modified": "Modificato",
"col_added": "Aggiunto",
"col_created_by": "Creato da",
"col_opened": "Aperto",
"col_path": "Posizione",
"new_elements": "Nuovi elementi"
},
"dialogs": {
"rename_folder": "Rinomina cartella",
@@ -461,7 +470,8 @@
"trashed_time": "Data di eliminazione"
},
"delete": "Elimina definitivamente",
"empty_action": "Svuota il cestino"
"empty_action": "Svuota il cestino",
"expires_at": "Scadenza"
},
"daysRemaining": {
"expired": "Scaduto",
@@ -582,7 +592,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",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Nessun membro",
"member_count": "{{n}} membri"
},
"resource_list": {
"location": "Posizione",
"wrong_drop_zone_msg": "I caricamenti funzionano solo in File — apri la sezione File e trascina lì gli elementi.",
"wrong_drop_zone_action": "Vai a File"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "メールで通知",
"revoke": "Remove",
"role_label": "役割"
"role_label": "役割",
"col_shared_by": "共有者",
"col_shared": "共有日時"
},
"share_dialogTitle": "共有リンク",
"share_linkLabel": "共有リンク:",
@@ -337,6 +339,7 @@
"modified": "更新日",
"no_files": "このフォルダにファイルはありません",
"empty_hint": "ファイルをアップロードするかフォルダを作成して始めましょう",
"drop_to_upload": "アップロードするファイルをここにドロップ",
"loading": "ファイルを読み込み中…",
"view_grid": "グリッド表示",
"view_list": "リスト表示",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "「{{name}}」に名前を変更しました — 設定により非表示になりました。",
"new_folder_dotfile_hidden": "フォルダ「{{name}}」を作成しました — 設定により非表示になっています。",
"dotfiles_hidden_toast": "非表示ファイルを隠しました",
"dotfiles_shown_toast": "非表示ファイルを表示しました"
"dotfiles_shown_toast": "非表示ファイルを表示しました",
"col_modified": "更新日時",
"col_added": "追加日",
"col_created_by": "作成者",
"col_opened": "アクセス日時",
"col_path": "場所",
"new_elements": "新しいアイテム"
},
"dialogs": {
"rename_folder": "フォルダ名を変更",
@@ -461,7 +470,8 @@
"trashed_time": "削除日時"
},
"delete": "完全に削除",
"empty_action": "ゴミ箱を空にする"
"empty_action": "ゴミ箱を空にする",
"expires_at": "期限"
},
"daysRemaining": {
"expired": "期限切れ",
@@ -582,7 +592,8 @@
"empty_hint": "開いたファイルがここに表示されます",
"empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります",
"empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。",
"loadMore": "さらに読み込む"
"loadMore": "さらに読み込む",
"remove_item": "最近使用したものから削除"
},
"notifications": {
"file_renamed": "ファイル名を変更しました",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "メンバーなし",
"member_count": "{{n}} 人のメンバー"
},
"resource_list": {
"location": "場所",
"wrong_drop_zone_msg": "アップロードはファイル セクションでのみ機能します。ファイル セクションを開いてそこにドロップしてください。",
"wrong_drop_zone_action": "ファイルへ移動"
}
}
+18 -4
View File
@@ -304,7 +304,9 @@
"public_link": "공개 링크",
"set_expiry": "만료일 설정",
"title": "공유됨",
"unlock": "잠금 해제"
"unlock": "잠금 해제",
"col_shared_by": "공유한 사람",
"col_shared": "공유일"
},
"share_dialogTitle": "공유 링크",
"share_linkLabel": "공유 링크:",
@@ -407,6 +409,7 @@
"modified": "수정일",
"no_files": "이 폴더에 파일이 없습니다",
"empty_hint": "파일을 업로드하거나 폴더를 만들어 시작하세요",
"drop_to_upload": "업로드할 파일을 여기에 놓으세요",
"loading": "파일 로딩 중…",
"view_grid": "그리드 보기",
"view_list": "목록 보기",
@@ -442,7 +445,10 @@
"batch_delete": "선택 항목 삭제",
"breadcrumb": "경로",
"cancel_selection": "선택 취소",
"col_modified": "날짜",
"col_modified": "수정일",
"col_added": "추가일",
"col_created_by": "만든 사람",
"col_opened": "열어본 날짜",
"col_path": "위치",
"confirm_batch_delete": "{{n}}개 항목을 휴지통으로 이동하시겠습니까?",
"confirm_delete": "\"{{name}}\"을(를) 휴지통으로 이동하시겠습니까?",
@@ -466,6 +472,7 @@
"move_title": "\"{{name}}\" 이동",
"moved": "이동됨",
"new_folder_prompt": "새 폴더 이름",
"new_elements": "새 항목",
"no_home": "홈 폴더를 사용할 수 없습니다.",
"no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.",
"no_subfolders": "하위 폴더가 없습니다.",
@@ -583,7 +590,8 @@
"empty_action": "휴지통 비우기",
"confirm_delete": "이 항목을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.",
"confirm_empty": "휴지통을 비우시겠습니까? 되돌릴 수 없습니다.",
"restored": "복원됨"
"restored": "복원됨",
"expires_at": "만료일"
},
"daysRemaining": {
"expired": "만료됨",
@@ -716,7 +724,8 @@
"empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개",
"empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.",
"loadMore": "더 불러오기",
"confirm_clear": "최근 항목을 지우시겠습니까?"
"confirm_clear": "최근 항목을 지우시겠습니까?",
"remove_item": "최근에서 제거"
},
"notifications": {
"file_renamed": "파일 이름이 변경되었습니다",
@@ -1659,5 +1668,10 @@
"group": {
"members_empty": "구성원 없음",
"member_count": "구성원 {{n}}명"
},
"resource_list": {
"location": "위치",
"wrong_drop_zone_msg": "업로드는 파일 섹션에서만 작동합니다. 파일 섹션을 열고 거기에 놓아 주세요.",
"wrong_drop_zone_action": "파일로 이동"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Per e-mail notificeren",
"revoke": "Remove",
"role_label": "Rol"
"role_label": "Rol",
"col_shared_by": "Gedeeld door",
"col_shared": "Gedeeld"
},
"share_dialogTitle": "Deellink",
"share_linkLabel": "Deellink:",
@@ -337,6 +339,7 @@
"modified": "Gewijzigd",
"no_files": "Geen bestanden in deze map",
"empty_hint": "Upload bestanden of maak mappen aan om te beginnen",
"drop_to_upload": "Sleep bestanden hier om te uploaden",
"loading": "Bestanden laden…",
"view_grid": "Rasterweergave",
"view_list": "Lijstweergave",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Hernoemd naar \"{{name}}\" — nu verborgen door je voorkeur.",
"new_folder_dotfile_hidden": "Map \"{{name}}\" aangemaakt — verborgen door je voorkeur.",
"dotfiles_hidden_toast": "Verborgen bestanden verborgen",
"dotfiles_shown_toast": "Verborgen bestanden weergegeven"
"dotfiles_shown_toast": "Verborgen bestanden weergegeven",
"col_modified": "Gewijzigd",
"col_added": "Toegevoegd",
"col_created_by": "Gemaakt door",
"col_opened": "Geopend",
"col_path": "Locatie",
"new_elements": "Nieuwe items"
},
"dialogs": {
"rename_folder": "Map hernoemen",
@@ -461,7 +470,8 @@
"trashed_time": "Verwijderd op"
},
"delete": "Permanent verwijderen",
"empty_action": "Prullenbak legen"
"empty_action": "Prullenbak legen",
"expires_at": "Verloopt op"
},
"daysRemaining": {
"expired": "Verlopen",
@@ -582,7 +592,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",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Geen leden",
"member_count": "{{n}} leden"
},
"resource_list": {
"location": "Locatie",
"wrong_drop_zone_msg": "Uploaden werkt alleen in Bestanden — open het onderdeel Bestanden en zet ze daar neer.",
"wrong_drop_zone_action": "Naar Bestanden"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Powiadom e-mailem",
"revoke": "Usuń",
"role_label": "Rola"
"role_label": "Rola",
"col_shared_by": "Udostępnione przez",
"col_shared": "Udostępnione"
},
"share_dialogTitle": "Link udostępniania",
"share_linkLabel": "Link udostępniania:",
@@ -337,6 +339,7 @@
"modified": "Zmodyfikowano",
"no_files": "Brak plików w tym folderze",
"empty_hint": "Prześlij pliki lub utwórz foldery, aby rozpocząć",
"drop_to_upload": "Upuść pliki tutaj, aby wysłać",
"loading": "Ładowanie plików…",
"view_grid": "Widok siatki",
"view_list": "Widok listy",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Zmieniono nazwę na \"{{name}}\" — teraz ukryty zgodnie z Twoją preferencją.",
"new_folder_dotfile_hidden": "Utworzono folder \"{{name}}\" — ukryty zgodnie z Twoją preferencją.",
"dotfiles_hidden_toast": "Ukryte pliki ukryte",
"dotfiles_shown_toast": "Ukryte pliki wyświetlone"
"dotfiles_shown_toast": "Ukryte pliki wyświetlone",
"col_modified": "Zmodyfikowano",
"col_added": "Dodano",
"col_created_by": "Utworzone przez",
"col_opened": "Otwarte",
"col_path": "Lokalizacja",
"new_elements": "Nowe elementy"
},
"dialogs": {
"rename_folder": "Zmień nazwę folderu",
@@ -461,7 +470,8 @@
"trashed_time": "Czas usunięcia"
},
"delete": "Usuń trwale",
"empty_action": "Opróżnij kosz"
"empty_action": "Opróżnij kosz",
"expires_at": "Wygasa"
},
"daysRemaining": {
"expired": "Wygasł",
@@ -582,7 +592,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",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Brak członków",
"member_count": "{{n}} członków"
},
"resource_list": {
"location": "Lokalizacja",
"wrong_drop_zone_msg": "Przesyłanie działa tylko w Plikach — otwórz sekcję Pliki i upuść tam pliki.",
"wrong_drop_zone_action": "Przejdź do Plików"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Notificar por e-mail",
"revoke": "Remove",
"role_label": "Função"
"role_label": "Função",
"col_shared_by": "Compartilhado por",
"col_shared": "Compartilhado"
},
"share_dialogTitle": "Link de compartilhamento",
"share_linkLabel": "Link compartilhado:",
@@ -337,6 +339,7 @@
"modified": "Modificado",
"no_files": "Nenhum arquivo nesta pasta",
"empty_hint": "Envie arquivos ou crie pastas para começar",
"drop_to_upload": "Solte arquivos aqui para enviar",
"loading": "Carregando arquivos…",
"view_grid": "Visualização em grade",
"view_list": "Visualização em lista",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Renomeado para \"{{name}}\" — agora oculto pela sua preferência.",
"new_folder_dotfile_hidden": "Pasta \"{{name}}\" criada — oculta pela sua preferência.",
"dotfiles_hidden_toast": "Arquivos ocultos ocultados",
"dotfiles_shown_toast": "Arquivos ocultos exibidos"
"dotfiles_shown_toast": "Arquivos ocultos exibidos",
"col_modified": "Modificado",
"col_added": "Adicionado",
"col_created_by": "Criado por",
"col_opened": "Aberto",
"col_path": "Localização",
"new_elements": "Novos itens"
},
"dialogs": {
"rename_folder": "Renomear pasta",
@@ -461,7 +470,8 @@
"trashed_time": "Data de exclusão"
},
"delete": "Excluir permanentemente",
"empty_action": "Esvaziar lixeira"
"empty_action": "Esvaziar lixeira",
"expires_at": "Expira em"
},
"daysRemaining": {
"expired": "Expirado",
@@ -582,7 +592,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",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Sem membros",
"member_count": "{{n}} membros"
},
"resource_list": {
"location": "Localização",
"wrong_drop_zone_msg": "Os envios só funcionam em Ficheiros — abra a secção Ficheiros e largue-os aí.",
"wrong_drop_zone_action": "Ir para Arquivos"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "Уведомить по e-mail",
"revoke": "Remove",
"role_label": "Роль"
"role_label": "Роль",
"col_shared_by": "Поделился",
"col_shared": "Общий доступ"
},
"share_dialogTitle": "Ссылка для обмена",
"share_linkLabel": "Ссылка:",
@@ -337,6 +339,7 @@
"modified": "Изменён",
"no_files": "В этой папке нет файлов",
"empty_hint": "Загрузите файлы или создайте папки, чтобы начать",
"drop_to_upload": "Перетащите файлы сюда для загрузки",
"loading": "Загрузка файлов…",
"view_grid": "Сетка",
"view_list": "Список",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "Переименовано в \"{{name}}\" — теперь скрыто в соответствии с вашими настройками.",
"new_folder_dotfile_hidden": "Папка \"{{name}}\" создана — скрыта в соответствии с вашими настройками.",
"dotfiles_hidden_toast": "Скрытые файлы скрыты",
"dotfiles_shown_toast": "Скрытые файлы показаны"
"dotfiles_shown_toast": "Скрытые файлы показаны",
"col_modified": "Изменен",
"col_added": "Добавлено",
"col_created_by": "Создано",
"col_opened": "Открыт",
"col_path": "Расположение",
"new_elements": "Новые элементы"
},
"dialogs": {
"rename_folder": "Переименовать папку",
@@ -461,7 +470,8 @@
"trashed_time": "Время удаления"
},
"delete": "Удалить навсегда",
"empty_action": "Очистить корзину"
"empty_action": "Очистить корзину",
"expires_at": "Истекает"
},
"daysRemaining": {
"expired": "Истёк",
@@ -582,7 +592,8 @@
"empty_hint": "Открытые вами файлы будут отображаться здесь",
"empty_hidden_state": "Недавних элементов скрыто: {{n}}",
"empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.",
"loadMore": "Загрузить ещё"
"loadMore": "Загрузить ещё",
"remove_item": "Удалить из недавних"
},
"notifications": {
"file_renamed": "Файл переименован",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "Нет участников",
"member_count": "{{n}} участников"
},
"resource_list": {
"location": "Расположение",
"wrong_drop_zone_msg": "Загрузки работают только в разделе Файлы — откройте раздел Файлы и перетащите туда.",
"wrong_drop_zone_action": "Перейти к файлам"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "透過郵件通知",
"revoke": "移除",
"role_label": "角色"
"role_label": "角色",
"col_shared_by": "分享者",
"col_shared": "分享日期"
},
"share_dialogTitle": "共享連結",
"share_linkLabel": "共享連結:",
@@ -337,6 +339,7 @@
"modified": "修改日期",
"no_files": "此資料夾中沒有檔案",
"empty_hint": "上傳檔案或建立資料夾以開始使用",
"drop_to_upload": "將檔案拖放到此處上傳",
"loading": "正在載入檔案…",
"view_grid": "網格檢視",
"view_list": "列表檢視",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "已重新命名為「{{name}}」——現已根據您的偏好隱藏。",
"new_folder_dotfile_hidden": "已建立資料夾「{{name}}」——根據您的偏好隱藏。",
"dotfiles_hidden_toast": "已隱藏隱藏檔案",
"dotfiles_shown_toast": "已顯示隱藏檔案"
"dotfiles_shown_toast": "已顯示隱藏檔案",
"col_modified": "修改日期",
"col_added": "新增日期",
"col_created_by": "建立者",
"col_opened": "開啟日期",
"col_path": "位置",
"new_elements": "新項目"
},
"dialogs": {
"rename_folder": "重新命名資料夾",
@@ -461,7 +470,8 @@
"trashed_time": "刪除時間"
},
"delete": "永久刪除",
"empty_action": "清空回收站"
"empty_action": "清空回收站",
"expires_at": "到期時間"
},
"daysRemaining": {
"expired": "已過期",
@@ -582,7 +592,8 @@
"empty_hint": "您開啟的檔案將顯示在這裡",
"empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目",
"empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。",
"loadMore": "載入更多"
"loadMore": "載入更多",
"remove_item": "從最近項目中移除"
},
"batch": {
"one_selected": "已選擇 1 個專案",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "無成員",
"member_count": "{{n}} 位成員"
},
"resource_list": {
"location": "位置",
"wrong_drop_zone_msg": "上傳僅在「檔案」中有效 — 請開啟檔案區並在該處拖放。",
"wrong_drop_zone_action": "前往檔案"
}
}
+20 -4
View File
@@ -234,7 +234,9 @@
"link_name": "Link name (optional)",
"notifyByEmail": "通过邮件通知",
"revoke": "Remove",
"role_label": "角色"
"role_label": "角色",
"col_shared_by": "共享者",
"col_shared": "共享日期"
},
"share_dialogTitle": "共享链接",
"share_linkLabel": "共享链接:",
@@ -337,6 +339,7 @@
"modified": "修改日期",
"no_files": "此文件夹中没有文件",
"empty_hint": "上传文件或创建文件夹以开始使用",
"drop_to_upload": "将文件拖放到此处上传",
"loading": "正在加载文件…",
"view_grid": "网格视图",
"view_list": "列表视图",
@@ -375,7 +378,13 @@
"rename_dotfile_hidden": "已重命名为「{{name}}」——现已根据您的偏好隐藏。",
"new_folder_dotfile_hidden": "已创建文件夹「{{name}}」——根据您的偏好隐藏。",
"dotfiles_hidden_toast": "已隐藏隐藏文件",
"dotfiles_shown_toast": "已显示隐藏文件"
"dotfiles_shown_toast": "已显示隐藏文件",
"col_modified": "修改日期",
"col_added": "添加日期",
"col_created_by": "创建者",
"col_opened": "打开日期",
"col_path": "位置",
"new_elements": "新元素"
},
"dialogs": {
"rename_folder": "重命名文件夹",
@@ -461,7 +470,8 @@
"trashed_time": "删除时间"
},
"delete": "永久删除",
"empty_action": "Empty trash"
"empty_action": "Empty trash",
"expires_at": "到期时间"
},
"daysRemaining": {
"expired": "已过期",
@@ -582,7 +592,8 @@
"empty_hint": "您打开的文件将显示在这里",
"empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目",
"empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。",
"loadMore": "加载更多"
"loadMore": "加载更多",
"remove_item": "从最近使用中移除"
},
"batch": {
"one_selected": "已选择 1 个项目",
@@ -1265,5 +1276,10 @@
"group": {
"members_empty": "无成员",
"member_count": "{{n}} 名成员"
},
"resource_list": {
"location": "位置",
"wrong_drop_zone_msg": "上传仅在 “文件” 中生效 — 请打开文件区并在那里拖放。",
"wrong_drop_zone_action": "前往文件"
}
}
+5
View File
@@ -127,6 +127,11 @@ pub struct FavoriteResourceRow {
/// folder rows. Routes into `FileDto::content_hash` and feeds
/// `File::compute_etag` to populate `FileDto::etag`.
pub blob_hash: Option<String>,
/// §14 provenance — who created the row. `None` when the creator
/// was deleted (FK `ON DELETE SET NULL`).
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
+7
View File
@@ -219,6 +219,13 @@ pub struct FolderResourceRow {
/// on the REST `/api/folders/{id}/resources` listing so API
/// consumers can issue conditional requests against listed files.
pub blob_hash: Option<String>,
/// §14 provenance — who created the row. `None` when the creator was
/// deleted (FK `ON DELETE SET NULL`). Populates
/// `FileDto::created_by` / `FolderDto::created_by` on the listing so
/// the UI can render the owner column without a follow-up query.
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
// Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String,
+10
View File
@@ -112,6 +112,16 @@ pub struct RecentResourceRow {
/// folder rows. Feeds `File::compute_etag` so this listing's
/// `etag` matches GET/HEAD/PROPFIND for the same file.
pub blob_hash: Option<String>,
/// §14 provenance — who created the row. `None` when the creator
/// was deleted (FK `ON DELETE SET NULL`). Powers the owner column
/// on the `/recent` UI (aligned with `/files` and `/favorites`
/// for cross-surface consistency, rather than the finer-grained
/// but noisier "who touched this last" signal).
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row. Not currently
/// consumed by the UI but surfaced for API parity with the other
/// listing endpoints.
pub updated_by: Option<Uuid>,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
+6
View File
@@ -71,6 +71,12 @@ pub struct TrashResourceRow {
/// same file (restorable trash items are conditional-request
/// targets too).
pub blob_hash: Option<String>,
/// §14 provenance — who created the row. `None` when the creator
/// was deleted (FK `ON DELETE SET NULL`).
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row (includes the trash
/// action itself, which stamps `updated_by = caller_id`).
pub updated_by: Option<Uuid>,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
+4 -6
View File
@@ -888,9 +888,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the trash listing query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -932,9 +931,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the trash listing query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -318,6 +318,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -350,6 +351,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.drive_id AS drive_id,
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -529,7 +531,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -602,6 +605,8 @@ LIMIT $6"
modified_at: row.get("modified_at"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
@@ -1446,6 +1446,8 @@ impl FolderDbRepository {
f.updated_at AS modified_at,
f.drive_id,
NULL::text AS blob_hash,
f.created_by,
f.updated_by,
LOWER(f.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
@@ -1465,6 +1467,8 @@ impl FolderDbRepository {
fm.updated_at AS modified_at,
fm.drive_id,
fm.blob_hash,
fm.created_by,
fm.updated_by,
LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order,
1::int AS folder_first
@@ -1655,6 +1659,7 @@ impl FolderDbRepository {
let sql = format!(
"SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, drive_id, blob_hash, \
created_by, updated_by, \
sort_str, type_order, folder_first \
FROM ({inner}) r \
{outer_order} \
@@ -1663,6 +1668,7 @@ impl FolderDbRepository {
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, drive_id, blob_hash,
// created_by, updated_by,
// sort_str, type_order, folder_first)
type Row = (
String,
@@ -1675,6 +1681,8 @@ impl FolderDbRepository {
chrono::DateTime<chrono::Utc>,
Uuid, // drive_id
Option<String>,
Option<Uuid>, // created_by
Option<Uuid>, // updated_by
String,
i64,
i32,
@@ -1706,9 +1714,11 @@ impl FolderDbRepository {
modified_at: r.7,
drive_id: r.8,
blob_hash: r.9,
sort_str: r.10,
type_order: r.11,
folder_first: r.12,
created_by: r.10,
updated_by: r.11,
sort_str: r.12,
type_order: r.13,
folder_first: r.14,
})
.collect())
}
@@ -244,6 +244,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -276,6 +277,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.drive_id AS drive_id,
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -454,7 +456,8 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -531,6 +534,8 @@ LIMIT $6"
modified_at: row.get("modified_at"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -367,6 +367,8 @@ impl TrashDbRepository {
fld.updated_at AS modified_at,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -393,6 +395,8 @@ impl TrashDbRepository {
f.updated_at AS modified_at,
f.drive_id AS drive_id,
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -522,7 +526,8 @@ impl TrashDbRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
@@ -581,6 +586,8 @@ LIMIT $6"
modified_at: row.get("modified_at"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -217,9 +217,8 @@ pub async fn list_favorites_resources(
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -266,9 +265,8 @@ pub async fn list_favorites_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the favorites query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -487,9 +487,8 @@ pub async fn list_folder_resources(
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -539,9 +538,8 @@ pub async fn list_folder_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the resources query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -237,9 +237,8 @@ pub async fn list_recent_resources(
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -284,9 +283,8 @@ pub async fn list_recent_resources(
sort_date: None,
content_hash,
etag,
// §14 provenance not selected by the recents query.
created_by: None,
updated_by: None,
created_by: row.created_by,
updated_by: row.updated_by,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
+32
View File
@@ -707,6 +707,38 @@ HTTP 200
jsonpath "$.created_by" == "{{alice_user_id}}"
jsonpath "$.updated_by" == "{{adam_user_id}}"
# ── D0 §14 provenance survives on the LISTING endpoint too ──
# The rename-response asserts above cover the mutation DTO, but
# /api/folders/{id}/resources has its own DTO-build path that
# used to hardcode created_by/updated_by = None (silent bug —
# owner column rendered "—" on /files for everyone). Hit the
# listing and re-assert both the untouched folder (both = alice)
# AND the Adam-renamed file (created_by=alice, updated_by=adam)
# on the same page — two shapes, one round-trip.
#
# Fixed indices are safe because at this point perm_folder_id
# holds exactly two rows and the default order_by=name puts
# 'perm-test-child' (folder) at [0] and 'adam-renamed-logo.jpg'
# (file) at [1]. Anything appended to this folder later in the
# scenario would break these indices — hence the assertion runs
# BEFORE the subsequent thumbnail/create/upload steps.
GET {{base_url}}/api/folders/{{perm_folder_id}}/resources
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$.items" count == 2
# [0] — untouched folder inherits Alice on both fields.
jsonpath "$.items[0].resource.name" == "perm-test-child"
jsonpath "$.items[0].resource.created_by" == "{{alice_user_id}}"
jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}"
# [1] — file Adam renamed. created_by stays alice (original
# uploader), updated_by is adam (last mutator). Canonical
# listing-side cross-user split.
jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg"
jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}"
jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}"
# ── Thumbnail push (Update) succeeds ────────────────────────
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview
Authorization: Bearer {{adam_token}}
+9 -9
View File
@@ -25,14 +25,14 @@ async function openFolderWithChildren(
await apiCreateFolder(page, c2, parent.id);
await page.goto(`/files/${parent.id}`);
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click();
await page.getByTestId('display-mode-view-list-btn').click();
return { c1, c2 };
}
test('select-all then batch favorite', async ({ page }) => {
const { c1 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible();
await page.getByTestId('files-batch-favorite-btn').click();
// Items remain in the folder after favoriting.
await expect(page.getByTestId(c1)).toBeVisible({ timeout: 15_000 });
@@ -40,8 +40,8 @@ test('select-all then batch favorite', async ({ page }) => {
test('select-all then batch copy and download', async ({ page }) => {
const { c1 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible();
// Copy → the move dialog (copy mode); cancel.
await page.getByTestId('files-batch-copy-btn').click();
@@ -49,8 +49,8 @@ test('select-all then batch copy and download', async ({ page }) => {
await page.getByTestId('move-dialog-cancel-btn').click();
// Re-select and batch-download (a zip).
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible();
const dl = page.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
await page.getByTestId('files-batch-download-btn').click();
await dl;
@@ -59,8 +59,8 @@ test('select-all then batch copy and download', async ({ page }) => {
test('select-all then batch delete', async ({ page }) => {
const { c1, c2 } = await openFolderWithChildren(page);
await page.getByTestId('files-select-all-checkbox').check();
await expect(page.getByTestId('files-batch-bar')).toBeVisible();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible();
await page.getByTestId('files-batch-delete-btn').click();
await page.getByTestId('dialog-host-confirm-btn').click();
+4 -4
View File
@@ -96,7 +96,7 @@ test('toolbar eye toggle hides and re-shows dotfiles in /files', async ({ page }
// click routes through `preferences.toggleHideDotfiles()` which
// does an optimistic local mutation, so the row update should be
// visible before the debounced PATCH lands.
await page.getByTestId('list-toolbar-dotfile-toggle-btn').click();
await page.getByTestId('display-mode-dotfile-toggle-btn').click();
// Visible row stays; hidden row vanishes.
await expect(page.getByTestId(visible)).toBeVisible();
@@ -104,7 +104,7 @@ test('toolbar eye toggle hides and re-shows dotfiles in /files', async ({ page }
// Flip it back off — the hidden row must reappear. Same button;
// its state flips atomically with `preferences.hideDotfiles`.
await page.getByTestId('list-toolbar-dotfile-toggle-btn').click();
await page.getByTestId('display-mode-dotfile-toggle-btn').click();
await expect(page.getByTestId(hidden)).toBeVisible();
});
@@ -130,7 +130,7 @@ test('empty-state hint appears when a folder holds only dotfiles', async ({ page
// Turn hide on. Folder becomes visually empty — but not the
// generic empty state; the "N hidden items" affordance appears
// instead, offering a one-click "Show hidden files" escape.
await page.getByTestId('list-toolbar-dotfile-toggle-btn').click();
await page.getByTestId('display-mode-dotfile-toggle-btn').click();
const showHiddenBtn = page.getByTestId('files-show-hidden-btn');
await expect(showHiddenBtn).toBeVisible({ timeout: 15_000 });
@@ -181,6 +181,6 @@ test('trash always shows dotfiles even when hide is on', async ({ page }) => {
// we just verify the toolbar toggle reflects the current server
// state via aria-pressed on /files.
await page.goto('/files');
const toggle = page.getByTestId('list-toolbar-dotfile-toggle-btn');
const toggle = page.getByTestId('display-mode-dotfile-toggle-btn');
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
});
+10 -6
View File
@@ -51,12 +51,16 @@ test('favorites batch select-all then move dialog', async ({ page }) => {
await page.goto('/favorites');
await expect(page.getByTestId(f1)).toBeVisible({ timeout: 15_000 });
// The select-all checkbox lives in the list-view header.
await page.getByTestId('list-toolbar-view-list-btn').click();
await page.getByTestId('display-mode-view-list-btn').click();
await page.getByTestId('resource-list-select-all-checkbox').check();
await expect(page.getByTestId('resource-list-batch-toolbar')).toBeVisible();
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible();
// Batch-move opens the move dialog; cancel it.
await page.getByTestId('favorites-batch-move-btn').click();
await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('move-dialog-cancel-btn').click();
// Batch-remove-from-favorite un-stars every selected row without
// touching the underlying file — the /favorites batch bar was
// trimmed to Download + Remove-from-favorite (destructive-to-content
// actions moved into the row context menu). Verify the two folders
// vanish from the list after the click.
await page.getByTestId('favorites-batch-remove-btn').click();
await expect(page.getByTestId(f1)).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(f2)).toHaveCount(0);
});
+17 -14
View File
@@ -20,12 +20,12 @@ test('sort columns and toggle list/grid views', async ({ page }) => {
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click();
await page.getByTestId('display-mode-view-list-btn').click();
// Column sort buttons live in the list-view header.
await page.getByTestId('files-sort-name-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('files-sort-size-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('files-sort-modified_at-btn').click({ timeout: 5_000 }).catch(() => {});
await page.getByTestId('list-toolbar-view-grid-btn').click();
await page.getByTestId('display-mode-view-grid-btn').click();
});
test('sort by every column and group by every dimension', async ({ page }) => {
@@ -37,17 +37,17 @@ test('sort by every column and group by every dimension', async ({ page }) => {
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
// List view exposes the column-sort buttons.
await page.getByTestId('list-toolbar-view-list-btn').click();
await page.getByTestId('display-mode-view-list-btn').click();
for (const col of ['name', 'owner', 'type', 'size', 'modified_at']) {
await page.getByTestId(`files-sort-${col}-btn`).click({ timeout: 3_000 }).catch(() => {});
}
// Flip the sort direction.
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('display-mode-sort-direction-btn').click({ timeout: 3_000 }).catch(() => {});
// Cycle through every group-by dimension.
for (const g of ['type', 'size', 'modifiedAt', 'createdAt']) {
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId(`list-toolbar-groupby-${g}-item`).click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('display-mode-groupby-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId(`display-mode-groupby-${g}-item`).click({ timeout: 3_000 }).catch(() => {});
}
});
@@ -58,8 +58,8 @@ test('group files by type', async ({ page }) => {
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-groupby-btn').click();
await page.getByTestId('list-toolbar-groupby-type-item').click();
await page.getByTestId('display-mode-groupby-btn').click();
await page.getByTestId('display-mode-groupby-type-item').click();
// The grouped (swimlane) view now renders; items remain visible.
await expect(page.getByTestId(SAMPLE_FILES.text().name)).toBeVisible();
});
@@ -109,12 +109,15 @@ test('deep-link ?file= opens the viewer', async ({ page }) => {
await page.goto(`/files/${folder.id}`);
await expect(page.getByTestId(f.name)).toBeVisible({ timeout: 15_000 });
// Extract the file id from a row action button, then deep-link to it.
const tid = await page
.locator('[data-testid^="files-file-share-"]')
// Extract the file id straight off the row — ResourceList tags every
// `.file-item` with `data-item-id={item.id}`. The pre-migration
// approach read `files-file-share-{id}` off a per-row share button
// that no longer exists (Share moved into the context menu).
const fileId = await page
.locator(`.file-item[data-testid="${f.name}"]`)
.first()
.getAttribute('data-testid');
const fileId = (tid ?? '').replace('files-file-share-', '');
.getAttribute('data-item-id');
if (!fileId) throw new Error(`could not resolve file id for ${f.name}`);
await page.goto(`/files/${folder.id}?file=${fileId}`);
await expect(page.getByTestId('file-viewer-dialog')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('file-viewer-close-btn').click();
@@ -194,7 +197,7 @@ test('keyboard select-all and escape in the files list', async ({ page }) => {
await page.locator('.files-page').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('Control+a');
await expect(page.getByTestId('files-batch-bar')).toBeVisible({ timeout: 5_000 }).catch(() => {});
await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible({ timeout: 5_000 }).catch(() => {});
await page.keyboard.press('Escape');
});
+10 -7
View File
@@ -23,12 +23,15 @@ test('recent shows accessed items, batch selection, and clear', async ({ page })
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
// Switch to list view (reveals the select-all header) and batch-select.
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
// /recent's batch bar was trimmed to Download + Remove-from-recent
// (destructive-to-content actions moved into the row context menu),
// so this exercises the new remove-from-recent batch instead of the
// old batch-move-into-dialog flow.
await page.getByTestId('display-mode-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
const selectAll = page.getByTestId('resource-list-select-all-checkbox');
if (await selectAll.isVisible().catch(() => false)) {
await selectAll.check();
await page.getByTestId('recent-batch-move-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('move-dialog-cancel-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('recent-batch-remove-btn').click({ timeout: 3_000 }).catch(() => {});
}
// Clear the history if the control is present.
@@ -45,16 +48,16 @@ test('recent grouping and sort cycle (ResourceList toolbar)', async ({ page }) =
await apiRecordRecent(page, 'folder', a.id);
await page.goto('/recent');
await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 });
await page.getByTestId('list-toolbar-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
await page.getByTestId('display-mode-view-list-btn').click({ timeout: 3_000 }).catch(() => {});
// Cycle every group-by dimension exposed by the shared ResourceList toolbar.
for (let i = 0; i < 5; i++) {
await page.getByTestId('list-toolbar-groupby-btn').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('display-mode-groupby-btn').click({ timeout: 2_000 }).catch(() => {});
await page
.locator('[data-testid^="list-toolbar-groupby-"][data-testid$="-item"]')
.locator('[data-testid^="display-mode-groupby-"][data-testid$="-item"]')
.nth(i)
.click({ timeout: 2_000 })
.catch(() => {});
}
await page.getByTestId('list-toolbar-sort-direction-btn').click({ timeout: 2_000 }).catch(() => {});
await page.getByTestId('display-mode-sort-direction-btn').click({ timeout: 2_000 }).catch(() => {});
});