Merge pull request #629 from EdouardVanbelle/refactor/front-resource-list
Refactor(front) restore legacy frontend features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -110,57 +110,55 @@ 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.
|
||||
/** 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.
|
||||
*/
|
||||
export const PAGE_EMIT_MIN_INTERVAL_MS = 150;
|
||||
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 fetchFolderListing(
|
||||
export async function fetchFolderPage(
|
||||
folderId: string,
|
||||
opts: {
|
||||
etag?: string;
|
||||
orderBy?: string;
|
||||
reverse?: boolean;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
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;
|
||||
} = {}
|
||||
): 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' });
|
||||
): 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');
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
@@ -171,25 +169,50 @@ export async function fetchFolderListing(
|
||||
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') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
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);
|
||||
}
|
||||
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;
|
||||
} while (cursor);
|
||||
// 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 } = {}
|
||||
): Promise<FolderListingResult> {
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await fetchFolderPage(folderId, {
|
||||
cursor,
|
||||
forceRefresh: opts.forceRefresh
|
||||
});
|
||||
folders.push(...page.folders);
|
||||
files.push(...page.files);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "انتقل إلى الملفات"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "برو به پروندهها"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "फ़ाइलों पर जाएँ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ファイルへ移動"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "파일로 이동"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Перейти к файлам"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "前往檔案"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "前往文件"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user