feat: improve Drive file browsing and actions

This commit is contained in:
2026-10-09 18:39:27 +09:00
parent 73ac13e802
commit e8828cd1b5
6 changed files with 680 additions and 169 deletions
@@ -0,0 +1,29 @@
<script lang="ts">
export type DriveIconName = 'folder' | 'markdown' | 'text' | 'image' | 'download' | 'upload' | 'plus' | 'search' | 'refresh' | 'more' | 'close' | 'link' | 'edit' | 'trash' | 'arrow' | 'back' | 'info';
let { name }: { name: DriveIconName } = $props();
const paths: Record<DriveIconName, string> = {
folder: 'M3 7V5h6l2 2h10v13H3Z',
markdown: 'M5 3h10l4 4v14H5ZM14 3v5h5M8 16v-5l3 3 3-3v5',
text: 'M5 3h10l4 4v14H5ZM14 3v5h5M8 12h8M8 16h6',
image: 'M3 3h18v18H3ZM3 17l6-6 4 4 3-3 5 5M16 7h.01',
download: 'M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5',
upload: 'M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5',
plus: 'M12 5v14M5 12h14',
search: 'M21 21l-6-6M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0',
refresh: 'M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 13 3M18 18A8 8 0 0 1 5 15',
more: 'M5 12h.01M12 12h.01M19 12h.01',
close: 'M6 6l12 12M6 18 18 6',
link: 'm10 13 4-4M8 15l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 3 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',
edit: 'm15 4 5 5M4 20l5-1L21 7l-5-5L4 14Z',
trash: 'M4 6h16M9 6V3h6v3M6 6l1 15h10l1-15M10 10v7M14 10v7',
arrow: 'm9 5 7 7-7 7',
back: 'm15 5-7 7 7 7',
info: 'M12 11v6M12 7h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0',
};
</script>
<svg aria-hidden="true" viewBox="0 0 24 24"><path d={paths[name]} /></svg>
<style>
svg { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
</style>
@@ -74,6 +74,9 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
message: "Revision conflict",
}, { status: 409 });
}
if (url.pathname.endsWith("/list")) {
return Response.json({ entries: [], next_after: null });
}
if (url.pathname.endsWith("/read-text")) {
return Response.json({
entry: entry("2", "alpha", revision),
@@ -126,3 +129,88 @@ test("Refreshing metadata never silently rebases an open rename form onto anothe
"my-name.txt",
);
});
test("Folder picker blocks failed reads, filters files and self, and follows paged destinations", async () => {
let failList = true;
const mutations: DriveMutationRequest[] = [];
const folder = (id: string) => ({
...entry(id, "alpha", "7", "folder"),
name: `Folder ${id}`,
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
const id = url.searchParams.get("id") ?? "1";
if (url.pathname.endsWith("/mutate")) {
mutations.push(JSON.parse(String(init?.body)));
return Response.json({
code: "conflict",
classification: "not_committed",
message: "Revision conflict",
}, { status: 409 });
}
if (url.pathname.endsWith("/list")) {
if (id !== "1") return Response.json({ entries: [], next_after: null });
if (failList) {
return Response.json(
{ code: "unavailable", message: "Unavailable" },
{ status: 503 },
);
}
return Response.json(
url.searchParams.has("after")
? { entries: [folder("5")], next_after: null }
: {
entries: [folder("2"), entry("3"), folder("4")],
next_after: "4",
},
);
}
return Response.json(folder(id));
}),
);
const view = render(DrivePage, { workspaceId: "alpha", nodeId: "2" });
await waitFor(() =>
expect(view.getByRole("button", { name: "Rename / move" })).toBeDefined()
);
await fireEvent.click(view.getByRole("button", { name: "Rename / move" }));
await waitFor(() =>
expect(view.getByRole("button", { name: "Retry folders" })).toBeDefined()
);
expect(
view.getByRole("button", { name: "Apply rename / move" }).hasAttribute(
"disabled",
),
).toBe(true);
expect(mutations).toHaveLength(0);
failList = false;
await fireEvent.click(view.getByRole("button", { name: "Retry folders" }));
await waitFor(() =>
expect(view.getByRole("button", { name: "Folder 4" })).toBeDefined()
);
expect(view.queryByRole("button", { name: "Folder 2" })).toBeNull();
expect(view.queryByRole("button", { name: "note.txt" })).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "More folders" }));
await waitFor(() =>
expect(view.getByRole("button", { name: "Folder 5" })).toBeDefined()
);
await fireEvent.click(view.getByRole("button", { name: "Folder 5" }));
await waitFor(() =>
expect(
view.getByRole("button", { name: "Apply rename / move" }).hasAttribute(
"disabled",
),
).toBe(false)
);
await fireEvent.click(
view.getByRole("button", { name: "Apply rename / move" }),
);
await waitFor(() => expect(mutations).toHaveLength(1));
expect(mutations[0].mutation).toMatchObject({
operation: "relocate",
expected_revision: "7",
id: { workspace_id: "alpha", node_id: "2" },
parent: { workspace_id: "alpha", node_id: "5" },
});
});
@@ -6,6 +6,7 @@
import { driveHref } from '#lib/workspace/resource-links.ts';
import { workspaceRoute, formatDate } from '#lib/workspace/api/http.ts';
import DriveDocument from './DriveDocument.svelte';
import DriveIcon from './DriveIcon.svelte';
import { previewKind, formatBytes, DRIVE_TEXT_MAX_BYTES } from './preview-policy.ts';
let { workspaceId, nodeId }: { workspaceId: string; nodeId?: string } = $props();
@@ -26,6 +27,17 @@
let searchText = $state('');
let includeText = $state(false);
let copyState = $state('');
let createMenu = $state(false);
let actionsMenu = $state(false);
let formDialog: HTMLDialogElement;
let returnFocus: HTMLElement | null = null;
let destination = $state<DriveEntry | null>(null);
let destinationFolders = $state<DriveEntry[]>([]);
let destinationNext = $state<string | null>(null);
let destinationBusy = $state(false);
let destinationError = $state<string | null>(null);
let destinationEpoch = 0;
let destinationAbort: AbortController | null = null;
let lifetime = 0;
let breadcrumbEpoch = 0;
let entry = $derived($controller.entry);
@@ -42,7 +54,8 @@
const ws = workspaceId;
const id = nodeId;
++lifetime;
form = null; formEntry = null; editing = false; localError = null; file = null; searchText = ''; copyState = '';
++destinationEpoch; destinationAbort?.abort();
form = null; formEntry = null; createMenu = false; actionsMenu = false; editing = false; localError = null; file = null; searchText = ''; copyState = '';
try {
void controller.select(ws, id ? { workspace_id: ws, node_id: parseDriveDecimal(id) } : undefined);
} catch { void controller.select(null); localError = 'Invalid Drive node ID.'; }
@@ -82,7 +95,14 @@
imageUrl = url;
return () => { URL.revokeObjectURL(url); };
});
onDestroy(() => { ++lifetime; ++breadcrumbEpoch; controller.dispose(); });
$effect(() => {
if (!formDialog) return;
if (form && !formDialog.open) {
formDialog.showModal();
formDialog.querySelector<HTMLInputElement>('input')?.focus();
} else if (!form && formDialog.open) formDialog.close();
});
onDestroy(() => { ++lifetime; ++breadcrumbEpoch; ++destinationEpoch; destinationAbort?.abort(); controller.dispose(); });
beforeNavigate(({ to, cancel }) => {
// Within the optional Drive route the controller keeps per-identity drafts.
// Leaving it destroys this session store; make that loss an explicit decision.
@@ -91,9 +111,46 @@
});
function openForm(next: typeof form): void {
if (!entry || !canWrite) return;
const active = document.activeElement;
returnFocus = active instanceof HTMLElement ? active.closest('details')?.querySelector('summary') ?? active : null;
createMenu = false; actionsMenu = false;
form = next; formEntry = entry; localError = null; file = null; newText = '';
name = next === 'relocate' ? entry.name : next === 'text' ? 'document.md' : '';
parentId = entry.parent?.node_id ?? entry.entry.node_id;
destination = null; destinationFolders = []; destinationNext = null; destinationError = null;
if (next === 'relocate') void loadDestination(entry.parent ?? undefined);
}
function closeForm(): void {
if (pending) return;
++destinationEpoch; destinationAbort?.abort();
form = null;
}
async function loadDestination(ref?: DriveEntry['entry'], append = false): Promise<void> {
const epoch = ++destinationEpoch;
const ws = workspaceId;
const snapshot = formEntry;
destinationAbort?.abort();
destinationAbort = new AbortController();
const signal = destinationAbort.signal;
destinationBusy = true; destinationError = null;
try {
const folder = ref ? await client.metadata(ws, ref, signal) : await client.root(ws, signal);
if (folder.kind !== 'folder') throw new Error('Choose a folder.');
const result = await client.list(ws, folder.entry, { limit: 100, after: append ? destinationNext ?? undefined : undefined }, signal);
if (epoch !== destinationEpoch || ws !== workspaceId || form !== 'relocate' || snapshot !== formEntry) return;
destination = folder; parentId = folder.entry.node_id;
const folders = result.entries.filter((item) => item.kind === 'folder' && item.entry.node_id !== snapshot?.entry.node_id);
destinationFolders = append ? [...destinationFolders, ...folders] : folders;
destinationNext = result.next_after;
} catch {
if (epoch === destinationEpoch && !signal.aborted) destinationError = 'Folders could not be loaded. Retry before moving this item.';
} finally {
if (epoch === destinationEpoch) destinationBusy = false;
}
}
function typeLabel(item: DriveEntry): string {
const type = previewKind(item);
return type === 'folder' ? 'Folder' : type === 'markdown' ? 'Markdown' : type === 'text' ? 'Text document' : type === 'image' ? 'Image' : 'File';
}
async function action(run: () => Promise<unknown>): Promise<void> {
const epoch = lifetime;
@@ -118,6 +175,7 @@
operation: 'create', parent: current.entry, name: file.name, content_type: file.type || 'application/octet-stream',
});
} else if (selectedForm === 'relocate') {
if (!destination || destinationBusy || destinationError) throw new Error('Choose an available destination folder.');
requestId = await controller.mutate({ operation: 'relocate', id: current.entry, expected_revision: current.revision, parent: { workspace_id: workspaceId, node_id: parseDriveDecimal(parentId) }, name });
} else {
requestId = await controller.mutate(selectedForm === 'folder' ? { operation: 'create_folder', parent: current.entry, name } : { operation: 'create_text', parent: current.entry, name, text: newText, content_type: 'text/markdown' });
@@ -162,132 +220,34 @@
}
</script>
<section class="drive-page" aria-labelledby="drive-heading" data-drive-ready={!$controller.loading}>
<header class="drive-heading">
<div>
<h1 id="drive-heading">{entry?.parent ? entry.name : 'Drive'}</h1>
<p class="drive-note">Shared files · latest only. Saving replaces the current content; there is no version history.</p>
</div>
<button type="button" disabled={$controller.loading || pending} onclick={() => action(() => entry ? controller.refresh() : controller.select(workspaceId, nodeId ? { workspace_id: workspaceId, node_id: nodeId } : undefined))}>Refresh</button>
</header>
<nav class="drive-breadcrumb" aria-label="Drive folders">
<a href={workspaceRoute(workspaceId, '/drive')}>Drive</a>
{#each ancestors.filter((a) => a.parent !== null) as ancestor (ancestor.entry.node_id)}
<span aria-hidden="true">/</span><a href={driveHref(ancestor.entry)}>{ancestor.name}</a>
{/each}
{#if entry?.parent}<span aria-hidden="true">/</span><span aria-current="page">{entry.name}</span>{/if}
</nav>
{#if breadcrumbError}<p class="drive-error" role="status">{breadcrumbError}</p>{/if}
<svelte:window onclick={(event) => {
if (!(event.target instanceof Element) || !event.target.closest('.drive-menu')) {
createMenu = false; actionsMenu = false;
}
}} onkeydown={(event) => {
if (event.key === 'Escape' && !form) {
const menu = document.activeElement?.closest<HTMLDetailsElement>('details.drive-menu');
if (menu?.open) {
menu.open = false;
menu.querySelector('summary')?.focus();
event.preventDefault();
}
createMenu = false; actionsMenu = false;
}
}} />
{#snippet notices()}
{#if localError}<p class="drive-error" role="alert">{localError}</p>{/if}
{#if $controller.error}<p class="drive-error" role="alert">{$controller.error}. No empty content or success is inferred from this failure.</p>{/if}
{#if denied}<p role="status">Read only / access denied by Backend. Editing and upload are unavailable.</p>{/if}
{#if $controller.loading}<p class="drive-state" role="status">Loading Drive…</p>{/if}
{#if entry}
<div class="drive-metadata">
<span>{entry.kind} · {formatBytes(entry.size)}</span>
<span>Updated {formatDate(entry.updated_at)} by {entry.updated_by}</span>
</div>
{#if entry.parent}
<div class="drive-toolbar">
{#if entry.kind === 'file'}<a class="drive-download" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}>Download</a>{/if}
<button type="button" onclick={copyUrl}>Copy latest URL</button>
{#if canEdit}<button type="button" onclick={() => editing = !editing}>{editing ? 'View' : 'Edit text'}</button>{/if}
{#if canWrite}
<button type="button" onclick={() => openForm('relocate')}>Rename / move</button>
{#if entry.kind === 'file'}<button type="button" onclick={() => openForm('replace')}>Replace file</button>{/if}
<button type="button" class="drive-danger" onclick={remove}>Delete</button>
{/if}
</div>
<p class="drive-url"><a href={driveHref(entry.entry)}>{driveHref(entry.entry)}</a> <span role="status">{copyState}</span></p>
{/if}
{#if kind === 'folder'}
<div class="drive-toolbar">
{#if canWrite}
<button type="button" onclick={() => openForm('folder')}>New folder</button>
<button type="button" onclick={() => openForm('text')}>New Markdown</button>
<button type="button" onclick={() => openForm('upload')}>Upload</button>
{/if}
</div>
<form class="drive-search" onsubmit={(event) => { event.preventDefault(); void action(() => controller.search(searchText, includeText)); }}>
<label>Search Drive <input type="search" bind:value={searchText} maxlength="256" /></label>
<label class="drive-checkbox"><input type="checkbox" bind:checked={includeText} /> Include bounded file text</label>
<button type="submit" disabled={$controller.loading || !searchText}>Search</button>
{#if $controller.search}<button type="button" onclick={() => action(() => controller.refresh())}>Show folder</button>{/if}
</form>
{#if $controller.search}<p class="drive-note">Search results across this Drive: {$controller.search.query}</p>{/if}
{#if !$controller.loading && !$controller.error && !$controller.entries.length}<p class="drive-state">{$controller.search ? 'No matching files.' : 'This folder is empty.'}</p>{/if}
{#if $controller.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard access to the bounded horizontal scroll region.) -->
<div class="drive-table-region" tabindex="0" role="region" aria-label="Drive entries">
<table class="drive-table">
<thead><tr><th>Name</th><th>Kind / size</th><th>Updated</th></tr></thead>
<tbody>
{#each $controller.entries as item (item.entry.node_id)}
<tr><td><a href={driveHref(item.entry)}>{item.name}</a></td><td>{item.kind} · {formatBytes(item.size)}</td><td>{formatDate(item.updated_at)}<br />{item.updated_by}</td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if $controller.nextAfter}<button type="button" disabled={$controller.loading} onclick={() => action(() => controller.loadMore())}>Load more</button>{/if}
{:else if kind === 'markdown' || kind === 'text'}
{#if $controller.truncated}<p role="status">Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.</p>{/if}
{#if draft?.conflict}
<div class="drive-conflict" role="alert"><strong>Revision conflict — your draft is retained.</strong><p>Another writer changed this file. Refresh displays the latest content without changing your draft or its expected revision. Copy your draft before explicitly discarding it; it is never automatically retried.</p></div>
{/if}
{#if editing && draft}
<div class="drive-editor">
<label>Draft (expected revision {draft.expectedRevision})
<textarea value={draft.text} oninput={(event) => controller.edit(event.currentTarget.value)} readonly={denied} spellcheck="false"></textarea>
</label>
<div class="drive-toolbar">
{#if canWrite}<button type="button" disabled={!dirty || draft.conflict || new TextEncoder().encode(draft.text).length > DRIVE_TEXT_MAX_BYTES} onclick={() => action(() => controller.save())}>Save</button>{/if}
<button type="button" disabled={unresolved} onclick={() => { if (confirm('Discard your draft and load the current Backend revision?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest</button>
<span role="status">{dirty ? 'Unsaved draft' : 'Saved content'} · {new TextEncoder().encode(draft.text).length} / 65536 bytes</span>
</div>
</div>
{/if}
{#if $controller.text !== null}
<article class="drive-preview" aria-label="Current saved content">
{#if $controller.text === ''}<p class="drive-note">Empty file.</p>{:else if kind === 'markdown'}<DriveDocument text={$controller.text} {workspaceId} />{:else}<pre>{$controller.text}</pre>{/if}
</article>
{/if}
{:else if kind === 'image'}
{#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}<p role="status">Image exceeds the 256 KiB preview limit. Download to view.</p>
{:else if imageFailed}<p class="drive-error" role="alert">Image could not be decoded. Download is available; no image is assumed.</p>
{:else if imageUrl}
{#key imageUrl}<img class="drive-image" src={imageUrl} alt={entry.name} onerror={(event) => { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key}
{/if}
{:else if kind === 'download'}<p class="drive-state">This file type is download-only. HTML, SVG and other active content are never rendered here.</p>{/if}
{/if}
{#if form && entry}
<form class="drive-form" onsubmit={(event) => { event.preventDefault(); void submitForm(); }}>
<h2>{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}</h2>
{#if form === 'replace' || form === 'relocate'}<p class="drive-note">Expected revision {formEntry?.revision}. Refresh does not retarget this form to a newer revision.</p>{/if}
{#if form === 'upload' || form === 'replace'}
<label>File (maximum 16 MiB)<input type="file" onchange={(event) => file = event.currentTarget.files?.[0] ?? null} disabled={pending} /></label>
{#if file}<p>{file.name} · {formatBytes(file.size)}</p>{/if}
{:else}
<label>Name<input bind:value={name} required maxlength="255" disabled={pending} /></label>
{#if form === 'relocate'}
<label>Destination folder node ID<input bind:value={parentId} required inputmode="numeric" pattern="[1-9][0-9]*" disabled={pending} /></label>
<button type="button" disabled={pending} onclick={() => { const epoch = lifetime; const previous = parentId; void action(async () => { const root = await client.root(workspaceId); if (epoch === lifetime && form === 'relocate' && parentId === previous) parentId = root.entry.node_id; }); }}>Use Drive root</button>
<p class="drive-note">Use the folder's stable URL ID. Rename / move updates DB metadata, not a copy of the file.</p>
{/if}
{#if form === 'text'}<label>Markdown<textarea bind:value={newText} disabled={pending}></textarea></label>{/if}
{/if}
<div class="drive-toolbar">
<button type="submit" disabled={!canWrite || (file !== null && file.size > DRIVE_FILE_MAX_BYTES)}>{pending ? 'Transferring / awaiting DB publication…' : form === 'relocate' ? 'Apply rename / move' : form === 'replace' ? 'Replace' : form === 'upload' ? 'Upload file' : 'Create'}</button>
{#if pending}<button type="button" onclick={() => controller.cancelPending()}>Cancel transfer / check outcome</button>{/if}
<button type="button" disabled={pending} onclick={() => form = null}>Cancel form</button>
</div>
</form>
{/if}
{#if $controller.error}<p class="drive-error" role="alert">{$controller.error}</p>{/if}
{#if denied}<p class="drive-note" role="status">Read only / access denied by Backend. Editing and upload are unavailable.</p>{/if}
{/snippet}
{#snippet receipts()}
{#if $controller.receipts.length}
<section class="drive-receipts" aria-label="Drive mutation outcomes">
{#each $controller.receipts as receipt (receipt.requestId)}
<div role="status">
<span>{receipt.operation}: {receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'}</span>
<div class="drive-receipt" role="status">
<span>{receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'}</span>
{#if receipt.error}<p class="drive-error">{receipt.error}</p>{/if}
{#if receipt.state === 'unknown'}<button type="button" onclick={() => reconcileReceipt(receipt.requestId)}>Check request result</button>{/if}
<details><summary>Request ID</summary><code>{receipt.requestId}</code></details>
@@ -295,43 +255,300 @@
{/each}
</section>
{/if}
{/snippet}
<section class="drive-page" aria-labelledby="drive-heading" data-drive-ready={!$controller.loading}>
<header class="drive-heading">
<div class="drive-location">
<nav class="drive-breadcrumb" aria-label="Drive folders">
<a href={workspaceRoute(workspaceId, '/drive')}>Drive</a>
{#each ancestors.filter((a) => a.parent !== null) as ancestor (ancestor.entry.node_id)}
<span aria-hidden="true">/</span><a href={driveHref(ancestor.entry)}>{ancestor.name}</a>
{/each}
{#if entry?.parent}<span aria-hidden="true">/</span><span aria-current="page">{entry.name}</span>{/if}
</nav>
<h1 id="drive-heading">{entry?.parent ? entry.name : 'Drive'}</h1>
<p class="drive-note">{kind === 'folder' || !entry ? 'Files shared with your workspace.' : `${typeLabel(entry)} · ${formatBytes(entry.size)}`}</p>
</div>
<div class="drive-toolbar">
<button class="icon-button" type="button" aria-label="Refresh" title="Refresh" disabled={$controller.loading || pending} onclick={() => action(() => entry ? controller.refresh() : controller.select(workspaceId, nodeId ? { workspace_id: workspaceId, node_id: nodeId } : undefined))}><DriveIcon name="refresh" /></button>
{#if entry && kind === 'folder' && canWrite}
<details class="drive-menu drive-create-menu" bind:open={createMenu}>
<summary><DriveIcon name="plus" />New</summary>
<div class="drive-menu-panel">
<button type="button" onclick={() => openForm('folder')}><DriveIcon name="folder" />New folder</button>
<button type="button" onclick={() => openForm('text')}><DriveIcon name="markdown" />New Markdown</button>
</div>
</details>
<button class="primary" type="button" onclick={() => openForm('upload')}><DriveIcon name="upload" />Upload</button>
{/if}
{#if entry?.parent}
{#if entry.kind === 'file'}<a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}><DriveIcon name="download" />Download</a>{/if}
{#if canEdit}<button class="primary" type="button" onclick={() => editing = !editing}><DriveIcon name="edit" />{editing ? 'View' : 'Edit text'}</button>{/if}
<details class="drive-menu" bind:open={actionsMenu}>
<summary class="icon-button" aria-label="More actions" title="More actions"><DriveIcon name="more" /></summary>
<div class="drive-menu-panel">
<button type="button" onclick={() => { actionsMenu = false; void copyUrl(); }}><DriveIcon name="link" />Copy latest URL</button>
{#if canWrite}
<button type="button" onclick={() => openForm('relocate')}><DriveIcon name="edit" />Rename / move</button>
{#if entry.kind === 'file'}<button type="button" onclick={() => openForm('replace')}><DriveIcon name="upload" />Replace file</button>{/if}
<button type="button" class="drive-danger" onclick={() => { actionsMenu = false; void remove(); }}><DriveIcon name="trash" />Delete</button>
{/if}
</div>
</details>
{/if}
</div>
</header>
{#if breadcrumbError}<p class="drive-error" role="status">{breadcrumbError}</p>{/if}
{#if !form}{@render notices()}{/if}
{#if copyState}<p class="drive-note" role="status">{copyState} {#if entry}<a href={driveHref(entry.entry)}>Latest file link</a>{/if}</p>{/if}
{#if $controller.loading}<p class="drive-state" role="status">Loading Drive…</p>{/if}
{#if entry}
{#if kind === 'folder'}
<div class="drive-browser">
<form class="drive-search" role="search" onsubmit={(event) => { event.preventDefault(); void action(() => controller.search(searchText, includeText)); }}>
<div class="drive-search-field">
<DriveIcon name="search" />
<input type="search" aria-label="Search Drive" placeholder="Search files and folders…" bind:value={searchText} maxlength="256" />
<button type="submit" class="icon-button" aria-label="Search" title="Search" disabled={$controller.loading || !searchText}><DriveIcon name="arrow" /></button>
</div>
<details class="drive-search-options"><summary>Search options</summary><label class="drive-checkbox"><input type="checkbox" bind:checked={includeText} />Search file contents</label></details>
{#if $controller.search}<button type="button" onclick={() => action(() => controller.refresh())}>Show folder</button>{/if}
</form>
<div class="drive-list-heading">
<span>{$controller.search ? `Results for “${$controller.search.query}”` : 'Files and folders'}</span>
<span>{$controller.entries.length}{$controller.nextAfter ? '+' : ''} items</span>
</div>
{#if !$controller.loading && !$controller.error && !$controller.entries.length}
<div class="drive-empty">
<span class="drive-empty-icon"><DriveIcon name={$controller.search ? 'search' : 'folder'} /></span>
<h2>{$controller.search ? 'No matching files.' : 'This folder is empty.'}</h2>
<p class="drive-note">{$controller.search ? 'Try a different name, or include file contents in your search.' : 'Upload a file or create a document to start sharing.'}</p>
</div>
{/if}
{#if $controller.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard access to the bounded horizontal scroll region.) -->
<div class="drive-table-region" tabindex="0" role="region" aria-label="Drive entries">
<table class="drive-table">
<thead><tr><th>Name</th><th>Size</th><th>Modified</th><th><span class="sr-only">Open</span></th></tr></thead>
<tbody>
{#each $controller.entries as item (item.entry.node_id)}
<tr>
<td><a class="drive-file" href={driveHref(item.entry)} aria-label={item.name}>
<span class="drive-file-icon" data-kind={previewKind(item)}><DriveIcon name={previewKind(item)} /></span>
<span class="drive-file-label"><span class="drive-file-name" title={item.name}>{item.name}</span><small>{typeLabel(item)}</small></span>
</a></td>
<td class="drive-file-size">{item.kind === 'folder' ? '—' : formatBytes(item.size)}</td>
<td class="drive-file-date">{formatDate(item.updated_at)}</td>
<td class="drive-row-arrow" aria-hidden="true"><DriveIcon name="arrow" /></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if $controller.nextAfter}<div class="drive-list-footer"><button type="button" disabled={$controller.loading} onclick={() => action(() => controller.loadMore())}>Load more</button></div>{/if}
</div>
{:else if kind === 'markdown' || kind === 'text'}
{#if $controller.truncated}<p class="drive-state" role="status">Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.</p>{/if}
{#if draft?.conflict}
<div class="drive-conflict" role="alert"><strong>Revision conflict — your draft is retained.</strong><p>Another writer changed this file. Refresh displays the latest content without changing your draft or its expected revision. Copy your draft before explicitly discarding it; it is never automatically retried.</p></div>
{/if}
<div class="drive-document" class:is-editing={editing && draft !== null}>
{#if editing && draft}
<div class="drive-editor">
<label>Draft (expected revision {draft.expectedRevision})
<textarea value={draft.text} oninput={(event) => controller.edit(event.currentTarget.value)} readonly={denied} spellcheck="false"></textarea>
</label>
<div class="drive-toolbar">
{#if canWrite}<button class="primary" type="button" disabled={!dirty || draft.conflict || new TextEncoder().encode(draft.text).length > DRIVE_TEXT_MAX_BYTES} onclick={() => action(() => controller.save())}>Save</button>{/if}
<button type="button" disabled={unresolved} onclick={() => { if (confirm('Discard your draft and load the current Backend revision?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest</button>
<span class="drive-note" role="status">{dirty ? 'Unsaved draft' : 'Saved content'} · {formatBytes(new TextEncoder().encode(draft.text).length)} / 64 KiB</span>
</div>
</div>
{/if}
{#if $controller.text !== null}
<article class="drive-preview" aria-label="Current saved content">
{#if editing}<p class="drive-preview-label">Saved version</p>{/if}
{#if $controller.text === ''}<p class="drive-note">Empty file.</p>{:else if kind === 'markdown'}<DriveDocument text={$controller.text} {workspaceId} />{:else}<pre>{$controller.text}</pre>{/if}
</article>
{/if}
</div>
{:else if kind === 'image'}
<div class="drive-image-stage">
{#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}<p class="drive-state" role="status">Image exceeds the 256 KiB preview limit. Download to view.</p>
{:else if imageFailed}<p class="drive-error" role="alert">Image could not be decoded. Download is available; no image is assumed.</p>
{:else if imageUrl}
{#key imageUrl}<img class="drive-image" src={imageUrl} alt={entry.name} onerror={(event) => { if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key}
{/if}
</div>
{:else if kind === 'download'}
<div class="drive-empty drive-download-preview"><span class="drive-empty-icon"><DriveIcon name="download" /></span><h2>No preview available</h2><p class="drive-note">This file type is download-only. HTML, SVG and other active content are never rendered here.</p><a class="drive-button" href={driveDownloadUrl(workspaceId, entry.entry, entry.revision)} download={entry.name}>Download file</a></div>
{/if}
{#if entry.parent}
<details class="drive-details"><summary><DriveIcon name="info" />Details</summary>
<dl><div><dt>Updated</dt><dd>{formatDate(entry.updated_at)}</dd></div><div><dt>By</dt><dd>{entry.updated_by}</dd></div><div><dt>Revision</dt><dd>{entry.revision}</dd></div><div><dt>Latest URL</dt><dd><a href={driveHref(entry.entry)}>{driveHref(entry.entry)}</a></dd></div></dl>
<p class="drive-note">Saving replaces the current content. There is no version history.</p>
</details>
{/if}
{/if}
{#if !form}{@render receipts()}{/if}
</section>
<dialog class="drive-dialog" bind:this={formDialog} aria-labelledby="drive-form-heading" oncancel={(event) => { event.preventDefault(); closeForm(); }} onclose={() => { form = null; if (returnFocus?.isConnected) returnFocus.focus(); }}>
{#if form && entry}
<form class="drive-form" onsubmit={(event) => { event.preventDefault(); void submitForm(); }}>
<header class="drive-dialog-heading">
<div><h2 id="drive-form-heading">{form === 'relocate' ? 'Rename / move' : form === 'replace' ? 'Replace file' : form === 'upload' ? 'Upload file' : form === 'folder' ? 'New folder' : 'New Markdown'}</h2><p class="drive-note">{form === 'replace' || form === 'relocate' ? formEntry?.name : `In ${entry.parent ? entry.name : 'Drive'}`}</p></div>
<button class="icon-button" type="button" aria-label="Close dialog" title="Close" disabled={pending} onclick={closeForm}><DriveIcon name="close" /></button>
</header>
{@render notices()}
{#if form === 'upload' || form === 'replace'}
<label class="drive-upload-picker"><span class="drive-empty-icon"><DriveIcon name="upload" /></span><strong>{file ? file.name : 'Choose a file to upload'}</strong><span class="drive-note">{file ? formatBytes(file.size) : 'Maximum file size: 16 MiB'}</span><input aria-label="File (maximum 16 MiB)" type="file" onchange={(event) => file = event.currentTarget.files?.[0] ?? null} disabled={pending} /></label>
{#if file && file.size > DRIVE_FILE_MAX_BYTES}<p class="drive-error" role="alert">Upload exceeds 16 MiB. Choose a smaller file.</p>{/if}
{:else}
<label>Name<input bind:value={name} required maxlength="255" disabled={pending} autocomplete="off" /></label>
{#if form === 'relocate'}
<fieldset class="drive-folder-picker" disabled={pending}>
<legend>Destination folder</legend>
<div class="drive-folder-location"><DriveIcon name="folder" /><strong>{destination ? destination.parent ? destination.name : 'Drive' : 'Loading…'}</strong>
<button type="button" class="icon-button" aria-label="Use Drive root" title="Drive root" disabled={destinationBusy} onclick={() => loadDestination()}><DriveIcon name="folder" /></button>
{#if destination?.parent}<button type="button" class="icon-button" aria-label="Parent folder" title="Parent folder" disabled={destinationBusy} onclick={() => loadDestination(destination?.parent ?? undefined)}><DriveIcon name="back" /></button>{/if}
</div>
{#if destinationError}<p class="drive-error" role="alert">{destinationError}</p><button type="button" onclick={() => loadDestination(destination?.entry)}>Retry folders</button>{/if}
{#if destinationBusy}<p class="drive-note" role="status">Loading folders…</p>{/if}
<div class="drive-folder-list">
{#each destinationFolders as folder (folder.entry.node_id)}<button type="button" disabled={destinationBusy} onclick={() => loadDestination(folder.entry)}><DriveIcon name="folder" /><span>{folder.name}</span><DriveIcon name="arrow" /></button>{/each}
{#if !destinationBusy && !destinationError && !destinationFolders.length}<p class="drive-note">No subfolders. This folder will be used.</p>{/if}
{#if destinationNext}<button type="button" disabled={destinationBusy} onclick={() => loadDestination(destination?.entry, true)}>More folders</button>{/if}
</div>
</fieldset>
{/if}
{#if form === 'text'}<label>Markdown<textarea bind:value={newText} disabled={pending} placeholder="Write your document…"></textarea></label>{/if}
{/if}
{#if form === 'replace' || form === 'relocate'}<p class="drive-note">Expected revision {formEntry?.revision}. Changes by another writer will not be overwritten.</p>{/if}
{#if form === 'replace'}<p class="drive-note">This replaces the current file. There is no version history.</p>{/if}
<footer class="drive-dialog-actions">
<button type="button" disabled={pending} onclick={closeForm}>Cancel form</button>
{#if pending}<button type="button" onclick={() => controller.cancelPending()}>Cancel transfer / check outcome</button>{/if}
<button class="primary" type="submit" disabled={!canWrite || ((form === 'upload' || form === 'replace') && (!file || file.size > DRIVE_FILE_MAX_BYTES)) || (form === 'relocate' && (!destination || destinationBusy || destinationError !== null))}>{pending ? 'Transferring…' : form === 'relocate' ? 'Apply rename / move' : form === 'replace' ? 'Replace' : form === 'upload' ? 'Upload file' : 'Create'}</button>
</footer>
{@render receipts()}
</form>
{/if}
</dialog>
<style>
.drive-page { display: grid; gap: var(--space-4); min-width: 0; max-width: 100%; }
.drive-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--space-4); }
.drive-heading > div { min-width: 0; }
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); overflow-wrap: anywhere; }
h2 { margin: 0; font-size: var(--font-size-body); }
.drive-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
.drive-location { min-width: 0; flex: 1 1 16rem; }
h1 { margin: var(--space-2) 0 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); overflow-wrap: anywhere; }
h2 { margin: 0; color: var(--text-strong); font-size: var(--font-size-body); }
p { margin: 0; overflow-wrap: anywhere; }
.drive-note, .drive-metadata, .drive-state { color: var(--text-muted); }
.drive-note { margin-top: var(--space-2); }
.drive-metadata { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--font-size-compact); }
.drive-note, .drive-state { color: var(--text-muted); font-size: var(--font-size-compact); }
.drive-location > .drive-note, .drive-dialog-heading .drive-note { margin-top: var(--space-1); }
.drive-toolbar, .drive-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.drive-breadcrumb { font-size: var(--font-size-compact); }
.drive-breadcrumb a, .drive-breadcrumb span, .drive-url { min-width: 0; overflow-wrap: anywhere; }
button, .drive-download { padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-strong); font-size: var(--font-size-body); cursor: pointer; }
button:hover:not(:disabled), .drive-download:hover { background: var(--interactive-hover); }
button:disabled { cursor: not-allowed; opacity: 0.55; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.drive-breadcrumb { font-size: var(--font-size-compact); color: var(--text-muted); }
.drive-breadcrumb a { color: inherit; }
.drive-breadcrumb a, .drive-breadcrumb span { min-width: 0; overflow-wrap: anywhere; }
button, .drive-button, .drive-menu > summary { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-strong); font: inherit; font-size: var(--font-size-compact); cursor: pointer; text-decoration: none; }
button:hover:not(:disabled), .drive-button:hover, .drive-menu > summary:hover { background: var(--interactive-hover); }
button.primary { background: var(--accent); color: var(--bg); border-color: var(--accent); }
button.primary:hover:not(:disabled) { filter: brightness(1.08); }
button:disabled { cursor: not-allowed; opacity: 0.5; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible, summary:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.icon-button, .drive-menu > summary.icon-button { width: 2.25rem; padding: var(--space-2); flex: none; }
a { color: var(--accent); }
.drive-error, .drive-danger { color: var(--danger); }
.drive-menu { position: relative; }
.drive-menu > summary { list-style: none; }
.drive-menu > summary::-webkit-details-marker { display: none; }
.drive-menu-panel { position: absolute; z-index: 5; inset-block-start: calc(100% + var(--space-2)); inset-inline-end: 0; min-width: 13rem; display: grid; padding: var(--space-1); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
.drive-menu-panel button { justify-content: flex-start; border-color: transparent; background: transparent; }
.drive-error, button.drive-danger { color: var(--danger); }
.drive-error, .drive-conflict { padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); font-size: var(--font-size-compact); }
.drive-conflict { display: grid; gap: var(--space-2); color: var(--warning); }
.drive-search { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: end; }
label { display: grid; gap: var(--space-1); min-width: 0; }
input, textarea { min-width: 0; max-width: 100%; padding: var(--space-2); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg); color: var(--text); font: inherit; }
.drive-checkbox { display: flex; align-items: center; gap: var(--space-2); padding-block: var(--space-2); }
.drive-browser { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius-soft); overflow: hidden; }
.drive-search { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); padding: var(--space-3); border-bottom: 1px solid var(--line); }
.drive-search-field { display: flex; align-items: center; gap: var(--space-2); color: var(--text-muted); flex: 1 1 16rem; min-width: 0; }
.drive-search-field input { border: 0; padding: var(--space-2) 0; background: transparent; flex: 1; width: 0; }
.drive-search-options { font-size: var(--font-size-compact); color: var(--text-muted); }
summary { cursor: pointer; }
.drive-checkbox { display: flex; align-items: center; gap: var(--space-2); padding-top: var(--space-2); }
.drive-list-heading { display: flex; justify-content: space-between; gap: var(--space-2); padding: var(--space-3); color: var(--text-muted); font-size: var(--font-size-compact); }
.drive-table-region { max-width: 100%; overflow-x: auto; }
.drive-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-compact); }
th, td { padding: var(--space-3) var(--space-2); text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
td:first-child { width: 50%; min-width: 10rem; overflow-wrap: anywhere; }
td:last-child { overflow-wrap: anywhere; }
.drive-form, .drive-editor { display: grid; gap: var(--space-3); padding-block: var(--space-4); border-top: 1px solid var(--line); }
textarea { width: 100%; min-height: 12rem; box-sizing: border-box; resize: vertical; font-family: var(--font-mono); }
.drive-preview { min-width: 0; }
.drive-preview pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
.drive-table { width: 100%; border-collapse: collapse; font-size: var(--font-size-compact); table-layout: fixed; }
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--text-muted); font-weight: 500; font-size: var(--font-size-compact); }
th:first-child { width: 50%; }
th:nth-child(2) { width: 15%; }
th:last-child { width: 2rem; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover, tbody tr:focus-within { background: var(--interactive-hover); }
.drive-file { display: flex; gap: var(--space-3); align-items: center; color: var(--text-strong); text-decoration: none; min-width: 0; }
.drive-file-icon { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; flex: none; border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-muted); }
.drive-file-icon :global(svg) { width: 1.4rem; height: 1.4rem; }
.drive-file-icon[data-kind='folder'] { color: var(--warning); }
.drive-file-icon[data-kind='markdown'], .drive-file-icon[data-kind='image'] { color: var(--accent); }
.drive-file-label { min-width: 0; display: grid; gap: var(--space-1); }
.drive-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drive-file-label small, .drive-file-size, .drive-file-date, .drive-row-arrow { color: var(--text-muted); }
.drive-file-date { overflow-wrap: anywhere; }
.drive-list-footer { display: flex; justify-content: center; padding: var(--space-3); border-top: 1px solid var(--line); }
.drive-empty { display: grid; justify-items: center; gap: var(--space-3); padding: var(--space-6) var(--space-4); text-align: center; }
.drive-empty-icon { color: var(--text-muted); }
.drive-empty-icon :global(svg) { width: 2.5rem; height: 2.5rem; }
.drive-document { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); }
.drive-preview { padding: clamp(1rem, 3vw, 2.5rem); min-width: 0; max-width: 64rem; margin-inline: auto; }
.drive-preview pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; font-size: var(--font-size-body); }
.drive-preview-label { margin-bottom: var(--space-4); color: var(--text-muted); font-size: var(--font-size-compact); }
.drive-editor { display: grid; gap: var(--space-3); padding: var(--space-4); border-bottom: 1px solid var(--line); }
.drive-image-stage { display: grid; place-items: center; padding: var(--space-4); min-width: 0; min-height: 12rem; border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); }
.drive-image { display: block; max-width: 100%; max-height: 60vh; object-fit: contain; }
.drive-receipts { display: grid; gap: var(--space-3); font-size: var(--font-size-compact); }
.drive-receipts code { overflow-wrap: anywhere; }
@media (max-width: 600px) { .drive-heading { flex-wrap: wrap; } .drive-search > label:first-child { width: 100%; } }
.drive-download-preview { border: 1px solid var(--line); border-radius: var(--radius-soft); }
.drive-details { font-size: var(--font-size-compact); color: var(--text-muted); }
.drive-details > summary { display: flex; align-items: center; gap: var(--space-2); width: fit-content; }
.drive-details dl { display: grid; gap: var(--space-2); margin-block: var(--space-3); }
.drive-details dl > div { display: grid; grid-template-columns: 5rem minmax(0, 1fr); gap: var(--space-3); }
.drive-details dd { margin: 0; overflow-wrap: anywhere; }
.drive-dialog { width: min(34rem, calc(100vw - 2rem)); max-width: none; max-height: calc(100dvh - 2rem); padding: 0; box-sizing: border-box; border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: var(--bg); box-shadow: 0 16px 64px rgb(0 0 0 / 0.25); }
.drive-dialog::backdrop { background: rgb(0 0 0 / 0.35); }
.drive-form { display: grid; gap: var(--space-4); padding: var(--space-5); min-width: 0; }
.drive-dialog-heading { display: flex; justify-content: space-between; gap: var(--space-3); align-items: start; }
.drive-dialog-heading > div { min-width: 0; }
.drive-dialog-heading h2 { font-size: var(--font-size-title); }
.drive-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); padding-top: var(--space-3); border-top: 1px solid var(--line); }
label { display: grid; gap: var(--space-2); min-width: 0; font-size: var(--font-size-compact); }
input, textarea { min-width: 0; max-width: 100%; box-sizing: border-box; padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text); font: inherit; }
textarea { width: 100%; min-height: 16rem; resize: vertical; font-family: var(--font-mono); }
.drive-upload-picker { justify-items: center; text-align: center; padding: var(--space-5) var(--space-3); border: 1px dashed var(--line); border-radius: var(--radius-soft); }
.drive-upload-picker strong { max-width: 100%; overflow-wrap: anywhere; }
input[type='file'] { width: 100%; border: 0; background: transparent; padding: var(--space-2); font-size: var(--font-size-compact); }
.drive-folder-picker { min-width: 0; margin: 0; padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); }
.drive-folder-picker legend { font-size: var(--font-size-compact); padding-inline: var(--space-1); }
.drive-folder-location { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.drive-folder-location strong { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--font-size-compact); }
.drive-folder-list { display: grid; gap: var(--space-1); max-height: 13rem; overflow-y: auto; }
.drive-folder-list > button { border-color: transparent; justify-content: flex-start; }
.drive-folder-list > button > span { flex: 1; text-align: left; overflow-wrap: anywhere; }
.drive-receipts { display: grid; gap: var(--space-2); font-size: var(--font-size-compact); }
.drive-receipt { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); padding-block: var(--space-2); border-top: 1px solid var(--line); }
.drive-receipt > span { color: var(--text-muted); }
.drive-receipt code { overflow-wrap: anywhere; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 600px) {
.drive-heading { gap: var(--space-3); }
.drive-location { flex-basis: 100%; }
.drive-toolbar { width: 100%; }
.drive-toolbar > .drive-menu:last-child { margin-inline-start: auto; }
.drive-create-menu .drive-menu-panel { inset-inline-start: 0; inset-inline-end: auto; }
.drive-search { gap: var(--space-2); }
.drive-search-field { flex-basis: 100%; }
.drive-file-date, th:nth-child(3), .drive-row-arrow, th:last-child { display: none; }
th:first-child { width: 72%; }
th:nth-child(2) { width: 28%; }
.drive-file { gap: var(--space-2); }
th, td { padding: var(--space-3) var(--space-2); }
.drive-form { padding: var(--space-4); }
.drive-editor .drive-toolbar { align-items: flex-start; }
}
</style>
@@ -47,4 +47,6 @@ Home/sidebar and Workers are captured under identical owner/member/error/empty/l
The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes.
The Drive browser suite also exercises the file-browser redesign at 1440/768/390/320px in light/dark themes: menu placement, native-dialog focus and Escape restoration, folder creation, search, and destination-folder navigation. Screenshots are written to `.svelte-kit/drive-redesign/` for the list, New menu, creation dialog, Markdown preview, and move dialog. Component tests cover failed/paged destination reads, exclusion of files and the moving folder itself, and pinned mutation revisions. These checks use the same fixture boundary, not a live Backend.
Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors.
@@ -68,6 +68,7 @@ for (const scheme of ["light", "dark"]) {
label: "Rename / move pinned revision",
fullPage: true,
interaction: [
{ action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' },
{ action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' },
{
action: "wait",
@@ -82,6 +83,7 @@ for (const scheme of ["light", "dark"]) {
fullPage: true,
interaction: [
{ action: "click", selector: '.drive-form button:has-text("Cancel form")' },
{ action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' },
{ action: "click", selector: '.drive-toolbar button:has-text("Replace file")' },
{
action: "wait",
@@ -244,6 +246,7 @@ for (const scheme of ["light", "dark"]) {
label: "Member after typed write denial",
fullPage: true,
interaction: [
{ action: "click", selector: '.drive-create-menu > summary' },
{
action: "click",
selector: '.drive-page button:has-text("New folder")',
@@ -261,12 +264,12 @@ for (const scheme of ["light", "dark"]) {
action: "wait",
ready: {
kind: "selector",
selector: '.drive-page:has-text("Read only / access denied by Backend")',
selector: '.drive-dialog:has-text("Read only / access denied by Backend")',
},
},
{
action: "press",
selector: ".drive-heading button",
selector: '.drive-dialog button[aria-label="Close dialog"]',
key: "Shift",
},
],
@@ -5,10 +5,14 @@ import { chromium, type Page, type Route } from "playwright";
import { Buffer } from "node:buffer";
import type { FixtureLog } from "../../../../tools/web-ux/drive-fixture/api.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
const ready = (page: Page) => page.locator('[data-drive-ready="true"]').waitFor();
const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true });
const draft = (page: Page) => page.getByRole("textbox", { name: /^Draft \(expected revision/ });
const preview = (page: Page) => page.getByRole("article", { name: "Current saved content" });
const ready = (page: Page) =>
page.locator('[data-drive-ready="true"]').waitFor();
const button = (page: Page, name: string) =>
page.getByRole("button", { name, exact: true });
const draft = (page: Page) =>
page.getByRole("textbox", { name: /^Draft \(expected revision/ });
const preview = (page: Page) =>
page.getByRole("article", { name: "Current saved content" });
function gate() {
let release!: () => void;
const promise = new Promise<void>((resolve) => release = resolve);
@@ -180,7 +184,9 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit
await draft(second).inputValue(),
"Second actor unsaved draft",
);
const casWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
const casWrites = (await logs(origin)).filter((x) =>
x.path === "/mutate"
);
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[
"3",
"1",
@@ -235,7 +241,7 @@ Deno.test("production Drive delayed old read cannot replace another file or work
await ready(page);
assert((await preview(page).textContent())!.includes("Second document"));
assertEquals(
await page.locator("main .drive-url a").getAttribute("href"),
await page.locator(".drive-details dd a").getAttribute("href"),
"/w/home-member/drive/4",
);
} finally {
@@ -321,7 +327,9 @@ Deno.test("production Drive delayed old save and status preserve destination dra
await button(page, "Save").click();
await page.getByText("Published — DB commit confirmed", { exact: false })
.last().waitFor();
const pinnedWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
const pinnedWrites = (await logs(origin)).filter((x) =>
x.path === "/mutate"
);
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [
["3", "1"],
["4", "1"],
@@ -342,12 +350,15 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
try {
await page.goto(`${origin}/w/home-owner/drive/3`);
await ready(page);
await page.getByLabel("More actions", { exact: true }).click();
await button(page, "Rename / move").click();
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
"renamed.md",
);
await page.getByRole("textbox", { name: "Destination folder node ID" })
.fill("2");
await page.getByRole("dialog").getByRole("button", {
name: "資料",
exact: true,
}).click();
await button(page, "Apply rename / move").click();
await page.getByRole("heading", { name: "renamed.md", exact: true })
.waitFor();
@@ -361,6 +372,7 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
await ready(page);
await page.getByRole("link", { name: "renamed.md", exact: true }).click();
await ready(page);
await page.getByLabel("More actions", { exact: true }).click();
await button(page, "Delete").click();
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
.waitFor();
@@ -368,6 +380,8 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
await rootFolder(page);
await page.getByRole("link", { name: "資料", exact: true }).click();
await ready(page);
await page.locator(".drive-menu > summary").filter({ hasText: /^New$/ })
.click();
await button(page, "New Markdown").click();
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
"renamed.md",
@@ -445,6 +459,7 @@ Deno.test("production Drive upload response loss and navigation cancellation que
await transmitted.promise;
await button(page, "Cancel transfer / check outcome").click();
await button(page, "Check request result").waitFor();
await button(page, "Cancel form").click();
await page.getByRole("link", { name: "資料", exact: true }).click();
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
await ready(page);
@@ -607,20 +622,26 @@ Deno.test("production Drive denial empty pages truncation unsafe content and ima
await page.getByText("This file type is download-only.", { exact: false })
.waitFor();
assertEquals(
await page.locator("main svg, main iframe, main object").count(),
await page.locator(
"main svg:not([aria-hidden]), main iframe, main object",
).count(),
0,
);
await page.goto(`${origin}/w/home-owner/drive/5`);
await ready(page);
await page.waitForFunction(() => {
const image = document.querySelector("main .drive-image");
return image instanceof HTMLImageElement && image.complete && image.naturalWidth === 1200 &&
return image instanceof HTMLImageElement && image.complete &&
image.naturalWidth === 1200 &&
image.naturalHeight === 800;
});
assertEquals(
await page.getByRole("img", { name: "landscape.png", exact: true }).evaluate((image) =>
image instanceof HTMLImageElement ? [image.naturalWidth, image.naturalHeight] : []
),
await page.getByRole("img", { name: "landscape.png", exact: true })
.evaluate((image) =>
image instanceof HTMLImageElement
? [image.naturalWidth, image.naturalHeight]
: []
),
[1200, 800],
);
await page.goto(`${origin}/w/home-owner/drive/7`);
@@ -713,3 +734,154 @@ Deno.test("production Drive Markdown safe image placeholders preserve each image
await page.close();
}
}));
Deno.test("production Drive menus dialogs folder picker and search remain usable at every viewport", () =>
harness(async (origin, browser) => {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
const capture = async (surface: string) => {
const geometry = await page.evaluate(() => {
const main = document.querySelector("main")!;
const dialog = document.querySelector("dialog[open]");
return {
viewport: innerWidth,
document: document.documentElement.scrollWidth,
main: main.clientWidth,
mainScroll: main.scrollWidth,
dialog: dialog?.clientWidth,
dialogScroll: dialog?.scrollWidth,
};
});
assert(geometry.document <= width, JSON.stringify(geometry));
assert(
geometry.mainScroll <= geometry.main + 1,
JSON.stringify(geometry),
);
if (geometry.dialog) {
assert(
geometry.dialogScroll! <= geometry.dialog + 1,
JSON.stringify(geometry),
);
}
await page.screenshot({
path: join(
root,
`web/workspace/.svelte-kit/drive-redesign/${surface}-${colorScheme}-${width}.png`,
),
});
};
try {
const reset = await fetch(`${origin}/__fixture/reset`, {
method: "POST",
});
await reset.body?.cancel();
await page.goto(`${origin}/w/home-owner/drive`);
await ready(page);
const newMenu = page.locator(".drive-menu > summary").filter({
hasText: /^New$/,
});
assertEquals(await button(page, "New folder").isVisible(), false);
await capture("files");
await newMenu.press("Enter");
await button(page, "New folder").waitFor();
const menuBox = await page.locator(
".drive-create-menu .drive-menu-panel",
).boundingBox();
assert(
menuBox && menuBox.x >= 0 && menuBox.x + menuBox.width <= width,
JSON.stringify(menuBox),
);
await capture("menu");
await button(page, "New folder").click();
const dialog = page.getByRole("dialog", {
name: "New folder",
exact: true,
});
await dialog.waitFor();
assert(
await dialog.getByLabel("Name", { exact: true }).evaluate((el) =>
el === document.activeElement
),
);
await capture("create");
for (let i = 0; i < 8; i++) {
await page.keyboard.press("Tab");
// Native dialogs may cycle through browser chrome (reported as body),
// but background page controls must stay inert.
assert(
await dialog.evaluate((el) =>
el.contains(document.activeElement) ||
document.activeElement === document.body
),
"Modal must not focus background controls",
);
}
await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden" });
await newMenu.locator(":scope:focus").waitFor();
await newMenu.press("Enter");
await button(page, "New folder").focus();
await page.keyboard.press("Escape");
assertEquals(await button(page, "New folder").isVisible(), false);
await newMenu.locator(":scope:focus").waitFor();
await newMenu.press("Enter");
await button(page, "New folder").click();
const folderName = `Created ${colorScheme} ${width}`;
await dialog.getByLabel("Name", { exact: true }).fill(folderName);
await button(page, "Create").click();
await dialog.waitFor({ state: "hidden" });
await page.getByRole("link", { name: folderName, exact: true })
.waitFor();
await page.getByRole("searchbox", { name: "Search Drive" }).fill(
folderName,
);
await button(page, "Search").click();
await button(page, "Show folder").waitFor();
await ready(page);
assertEquals(await page.locator(".drive-table tbody tr").count(), 1);
await button(page, "Show folder").click();
await ready(page);
await page.getByRole("link", { name: "README.md", exact: true })
.click();
await page.waitForURL("**/w/home-owner/drive/3");
await ready(page);
await preview(page).waitFor();
await capture("preview");
const more = page.getByLabel("More actions", { exact: true });
await more.click();
await button(page, "Rename / move").click();
const move = page.getByRole("dialog", {
name: "Rename / move",
exact: true,
});
await move.getByRole("button", { name: "資料", exact: true }).click();
await page.locator(".drive-folder-location strong").filter({
hasText: "資料",
}).waitFor();
await capture("move");
await button(page, "Parent folder").click();
await move.getByRole("button", { name: "資料", exact: true })
.waitFor();
assertEquals(
await move.getByRole("button", { name: "README.md", exact: true })
.count(),
0,
);
await page.keyboard.press("Escape");
await move.waitFor({ state: "hidden" });
await more.locator(":scope:focus").waitFor();
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
}));