Split browser workflows from presentation

This commit is contained in:
ms2k 2026-07-20 03:37:35 +09:00
commit 0c2d992224
25 changed files with 1683 additions and 854 deletions

File diff suppressed because it is too large Load diff

342
web/src/app.css Normal file
View file

@ -0,0 +1,342 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 20rem;
background: #f4f0e7;
color: #1e2a25;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.55;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 4.5rem;
padding: 0 4vw;
border-bottom: 1px solid #d9d4c8;
background: rgb(255 253 247 / 88%);
}
.brand,
.account,
.toolbar,
.roots,
.error {
display: flex;
gap: 0.75rem;
align-items: center;
}
.brand {
color: inherit;
font-weight: 750;
text-decoration: none;
}
.mark {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 0.55rem 0.55rem 0.55rem 0.1rem;
background: #bd4b30;
color: white;
font-family: Georgia, serif;
}
main {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 3.5rem 0 6rem;
}
.hero,
.resume-delete,
.dropzone,
.toolbar,
.list article {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hero {
align-items: end;
gap: 2rem;
}
.eyebrow {
margin: 0 0 0.35rem;
color: #bd4b30;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2.8rem, 7vw, 5.8rem);
font-weight: 500;
letter-spacing: -0.055em;
line-height: 0.95;
}
.hero p:last-child {
max-width: 38rem;
color: #68716c;
}
.roots {
padding: 0.3rem;
border: 1px solid #d6d0c4;
border-radius: 999px;
background: #ebe6dc;
}
.roots button {
padding: 0.6rem 1rem;
border: 0;
border-radius: 999px;
background: transparent;
}
.roots button.active {
background: white;
box-shadow: 0 0.15rem 0.6rem rgb(30 42 37 / 12%);
}
.roots button.shared.active {
background: #713c30;
color: white;
}
.shared-warning,
.resume-delete,
.notice,
.error,
.partial {
margin: 1.5rem 0;
padding: 0.9rem 1rem;
border-radius: 0.7rem;
}
.shared-warning {
border: 1px solid #d9a78d;
background: #fff0e7;
}
.resume-delete {
border: 1px solid #d2b665;
background: #fff4cd;
}
.notice {
background: #e3efe6;
color: #245c35;
}
.error {
background: #f8dfdc;
color: #852d25;
}
.partial {
background: #e4ecf4;
color: #2c526d;
}
.breadcrumbs {
display: flex;
gap: 0.35rem;
align-items: center;
min-height: 3rem;
margin-top: 1.5rem;
color: #7b817d;
}
.breadcrumbs button,
.quiet {
border: 0;
background: transparent;
color: #4c6258;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.dropzone {
min-height: 8rem;
padding: 1.5rem;
border: 2px dashed #b8b2a6;
border-radius: 1rem;
background: rgb(255 253 247 / 70%);
}
.dropzone.dragging {
border-color: #bd4b30;
background: #fff0e7;
}
.dropzone div,
.dropzone label {
display: grid;
gap: 0.35rem;
}
.dropzone span,
.list small,
.empty span {
color: #68716c;
}
.dropzone label {
padding: 0.7rem 1rem;
border-radius: 0.7rem;
background: #1e2a25;
color: white;
cursor: pointer;
text-align: center;
}
.dropzone input {
width: 0.1px;
height: 0.1px;
opacity: 0;
}
.browser {
margin-top: 1.5rem;
overflow: hidden;
border: 1px solid #d9d4c8;
border-radius: 1rem;
background: #fffdf7;
}
.toolbar {
padding: 1rem;
border-bottom: 1px solid #e1ddd3;
}
.toolbar form {
display: grid;
gap: 0.35rem;
}
.toolbar form > div {
display: flex;
gap: 0.5rem;
}
.toolbar input {
min-width: min(22rem, 45vw);
padding: 0.55rem 0.7rem;
border: 1px solid #b8b2a6;
border-radius: 0.5rem;
}
.toolbar button,
.load-more,
.resume-delete button,
.error button,
.delete {
padding: 0.55rem 0.8rem;
border: 1px solid #b8b2a6;
border-radius: 0.5rem;
background: white;
}
.list article {
border-bottom: 1px solid #eeeae1;
}
.entry {
display: flex;
flex: 1;
gap: 0.8rem;
align-items: center;
padding: 0.9rem 1rem;
border: 0;
background: transparent;
color: inherit;
text-align: left;
}
.entry > span:last-child {
display: grid;
}
.icon {
display: grid;
width: 2.2rem;
height: 2.2rem;
place-items: center;
border-radius: 0.6rem;
background: #e4ecf4;
color: #2c526d;
}
.icon.folder {
background: #f2e7c7;
color: #765f1d;
}
.delete {
margin-right: 1rem;
color: #852d25;
}
.delete.danger {
background: #852d25;
color: white;
}
.empty {
display: grid;
gap: 0.35rem;
place-items: center;
min-height: 12rem;
}
.load-more {
display: block;
margin: 1rem auto;
}
@media (max-width: 44rem) {
.hero,
.toolbar,
.dropzone,
.resume-delete {
align-items: stretch;
flex-direction: column;
}
.roots {
align-self: flex-start;
}
.toolbar input {
min-width: 0;
width: 100%;
}
}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { SessionResponse } from '../generated/contract.ts';
type Props = {
session: SessionResponse | null;
onlogout: () => Promise<void>;
};
let { session, onlogout }: Props = $props();
</script>
<header>
<a class="brand" href="/app" aria-label="Nuthatch home">
<span class="mark">N</span>
<span>Nuthatch</span>
</a>
<div class="account">
<span>{session?.display ?? session?.user_id ?? 'Loading session…'}</span>
<button class="quiet" type="button" onclick={() => void onlogout()}>Log out</button>
</div>
</header>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { Breadcrumb } from '../presentation.ts';
type Props = {
root: string;
items: Breadcrumb[];
busy: boolean;
onopen: (path: string) => Promise<void>;
};
let { root, items, busy, onopen }: Props = $props();
</script>
<nav class="breadcrumbs" aria-label="Current folder">
<button type="button" onclick={() => void onopen('')} disabled={busy}>{root}</button>
{#each items as crumb}
<span>/</span>
<button type="button" onclick={() => void onopen(crumb.path)} disabled={busy}>
{crumb.name}
</button>
{/each}
</nav>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { MutationFollowup } from '../mutations.ts';
type Props = {
notice: string;
error: string;
uploadPending: boolean;
uploadFollowup: MutationFollowup;
deletePending: boolean;
deleteFollowup: MutationFollowup;
onuploadretry: () => Promise<void>;
ondeleteretry: () => Promise<void>;
};
let {
notice,
error,
uploadPending,
uploadFollowup,
deletePending,
deleteFollowup,
onuploadretry,
ondeleteretry
}: Props = $props();
</script>
{#if notice !== ''}
<p class="notice" role="status">{notice}</p>
{/if}
{#if error !== ''}
<div class="error" role="alert">
<span>{error}</span>
{#if uploadPending}
<button type="button" onclick={() => void onuploadretry()}>
{uploadFollowup === 'restart' ? 'Restart complete upload' : 'Retry same upload'}
</button>
{/if}
{#if deletePending}
<button type="button" onclick={() => void ondeleteretry()}>
{deleteFollowup === 'restart'
? 'Start new delete operation'
: 'Retry same delete'}
</button>
{/if}
</div>
{/if}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import type { EntryResponse, Root } from '../generated/contract.ts';
import { formatSize } from '../presentation.ts';
type Props = {
root: Root;
entries: EntryResponse[];
continuation: string | null;
partial: boolean;
busy: boolean;
query: string;
onquery: (query: string) => void;
onbrowse: (append: boolean) => Promise<void>;
onopen: (entry: EntryResponse) => Promise<void>;
ondelete: (entry: EntryResponse) => Promise<void>;
};
let {
root,
entries,
continuation,
partial,
busy,
query,
onquery,
onbrowse,
onopen,
ondelete
}: Props = $props();
function submitSearch(event: SubmitEvent): void {
event.preventDefault();
void onbrowse(false);
}
function changeQuery(event: Event): void {
onquery((event.currentTarget as HTMLInputElement).value);
}
function clearSearch(): void {
onquery('');
void onbrowse(false);
}
</script>
<section class="browser">
<div class="toolbar">
<form onsubmit={submitSearch}>
<label for="search">Search this root</label>
<div>
<input
id="search"
value={query}
oninput={changeQuery}
placeholder="Case-insensitive substring"
disabled={busy}
/>
<button type="submit" disabled={busy}>Search</button>
{#if query !== ''}
<button class="quiet" type="button" disabled={busy} onclick={clearSearch}>
Clear
</button>
{/if}
</div>
</form>
<button
class="quiet"
type="button"
onclick={() => void onbrowse(false)}
disabled={busy}>Refresh</button
>
</div>
{#if partial}
<p class="partial" role="status">
Partial search results — the configured key or time bound was reached.
</p>
{/if}
<div class="list" aria-busy={busy}>
{#each entries as entry}
<article>
<button
class="entry"
type="button"
disabled={busy}
onclick={() => void onopen(entry)}
>
<span class:folder={entry.is_prefix} class="icon">
{entry.is_prefix ? '↳' : '↓'}
</span>
<span>
<strong>{entry.name}{entry.is_prefix ? '/' : ''}</strong>
<small>{entry.is_prefix ? 'Folder' : formatSize(entry.size)}</small>
</span>
</button>
<button
class:danger={root === 'shared'}
class="delete"
type="button"
onclick={() => void ondelete(entry)}
disabled={busy}>Delete</button
>
</article>
{:else}
<div class="empty">
<strong>{busy ? 'Loading…' : 'Nothing here yet'}</strong>
<span>{query === '' ? 'Drop a file above to begin.' : 'Try another search.'}</span>
</div>
{/each}
</div>
{#if continuation !== null && query === ''}
<button
class="load-more"
type="button"
onclick={() => void onbrowse(true)}
disabled={busy}>Load more</button
>
{/if}
</section>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { PendingDelete } from '../mutations.ts';
type Props = {
pending: PendingDelete | null;
busy: boolean;
onresume: () => Promise<void>;
};
let { pending, busy, onresume }: Props = $props();
</script>
{#if pending !== null}
<aside class="resume-delete">
<span>
Recursive deletion of <strong>{pending.prefix}</strong> has unfinished per-key
operations.
</span>
<button type="button" onclick={() => void onresume()} disabled={busy}>
Resume deletion
</button>
</aside>
{/if}

View file

@ -0,0 +1,46 @@
<script lang="ts">
type Props = {
busy: boolean;
onfile: (file: File) => Promise<void>;
};
let { busy, onfile }: Props = $props();
let dragging = $state(false);
function chooseFile(event: Event): void {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file !== undefined) void onfile(file);
}
function dropFile(event: DragEvent): void {
event.preventDefault();
dragging = false;
const file = event.dataTransfer?.files[0];
if (file !== undefined) void onfile(file);
}
</script>
<section
role="group"
aria-label="Upload file"
class:dragging
class="dropzone"
ondragenter={(event) => {
event.preventDefault();
dragging = true;
}}
ondragover={(event) => event.preventDefault()}
ondragleave={() => (dragging = false)}
ondrop={dropFile}
>
<div>
<strong>Drop a file here</strong>
<span>or choose one to upload into this folder</span>
</div>
<label>
Choose file
<input type="file" onchange={chooseFile} disabled={busy} />
</label>
</section>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import type { Root } from '../generated/contract.ts';
import { rootHeading } from '../presentation.ts';
type Props = {
root: Root;
busy: boolean;
onselect: (root: Root) => Promise<void>;
};
let { root, busy, onselect }: Props = $props();
</script>
<section class="hero">
<div>
<p class="eyebrow">Object workspace</p>
<h1>{rootHeading(root)}</h1>
<p>
{root === 'personal'
? 'Private to your verified identity.'
: 'Visible and mutable by every authenticated user.'}
</p>
</div>
<div class="roots" aria-label="Storage root">
<button
class:active={root === 'personal'}
type="button"
disabled={busy}
onclick={() => void onselect('personal')}>Personal</button
>
<button
class:active={root === 'shared'}
class:shared={root === 'shared'}
type="button"
disabled={busy}
onclick={() => void onselect('shared')}>Shared</button
>
</div>
</section>
{#if root === 'shared'}
<aside class="shared-warning">
<strong>Shared root</strong>
Deletions and replacements affect everyone. Nuthatch asks for confirmation before
destructive actions.
</aside>
{/if}

View file

@ -1,5 +1,6 @@
import { mount } from 'svelte'; import { mount } from 'svelte';
import App from './App.svelte'; import App from './App.svelte';
import './app.css';
const target = document.getElementById('app'); const target = document.getElementById('app');

40
web/src/presentation.ts Normal file
View file

@ -0,0 +1,40 @@
import type { Root } from './generated/contract.ts';
export type Breadcrumb = {
name: string;
path: string;
};
export function breadcrumbs(prefix: string): Breadcrumb[] {
if (prefix === '') return [];
return prefix.split('/').map((name, index, segments) => ({
name,
path: segments.slice(0, index + 1).join('/')
}));
}
export function parentPrefix(prefix: string): string {
return prefix.split('/').slice(0, -1).join('/');
}
export function uploadPath(prefix: string, fileName: string): string {
return prefix === '' ? fileName : `${prefix}/${fileName}`;
}
export function errorMessage(caught: unknown): string {
return caught instanceof Error ? caught.message : String(caught);
}
export function formatSize(size: number | null): string {
if (size === null) return '—';
return new Intl.NumberFormat(undefined, {
style: 'unit',
unit: 'byte',
notation: 'compact',
unitDisplay: 'narrow'
}).format(size);
}
export function rootHeading(root: Root): string {
return root === 'personal' ? 'Your files' : 'Shared files';
}

View file

@ -145,6 +145,24 @@ export class BrowserProductClient implements ProductEndpoint {
); );
} }
async logout(csrfToken: string): Promise<void> {
const response = await this.fetchRequest(
this.baseUrl === '' ? '/logout' : new URL('/logout', this.baseUrl),
{
method: 'POST',
credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrfToken }
}
);
if (!response.ok) {
throw new ProductRequestError({
kind: 'unexpected-http',
status: response.status,
message: response.statusText
});
}
}
private async jsonRequest<Success, Code extends string>( private async jsonRequest<Success, Code extends string>(
contract: EndpointContract, contract: EndpointContract,
request: object, request: object,

1
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,67 @@
import type {
BrowseResponse,
EntryResponse,
ProductEndpoint,
Root
} from '../generated/contract.ts';
import { requireValue } from '../transport.ts';
export type BrowseState = {
root: Root;
prefix: string;
query: string;
entries: EntryResponse[];
continuation: string | null;
partial: boolean;
};
export function initialBrowseState(): BrowseState {
return {
root: 'personal',
prefix: '',
query: '',
entries: [],
continuation: null,
partial: false
};
}
export async function loadBrowse(
client: ProductEndpoint,
state: BrowseState,
append: boolean
): Promise<BrowseResponse> {
return requireValue(
await client.browse({
root: state.root,
...(state.prefix === '' ? {} : { prefix: state.prefix }),
...(state.query === '' ? {} : { q: state.query }),
...(append && state.continuation !== null
? { continuation: state.continuation }
: {})
})
);
}
export function applyBrowsePage(
state: BrowseState,
page: BrowseResponse,
append: boolean
): void {
state.entries = append ? [...state.entries, ...page.entries] : page.entries;
state.continuation = page.continuation;
state.partial = page.partial;
}
export function selectRoot(state: BrowseState, root: Root): void {
state.root = root;
state.prefix = '';
state.query = '';
state.continuation = null;
}
export function openPrefix(state: BrowseState, prefix: string): void {
state.prefix = prefix;
state.query = '';
state.continuation = null;
}

View file

@ -0,0 +1,52 @@
import type { DeleteBatchItem, Root } from '../generated/contract.ts';
import {
decodePendingDelete,
encodePendingDelete,
readPendingDelete,
type PendingDelete
} from '../mutations.ts';
const recursiveDeleteStorageKey = 'nuthatch-recursive-delete';
export type RecursiveDeletePersistence = {
read(): PendingDelete | null;
load(root: Root, prefix: string): DeleteBatchItem[];
save(root: Root, prefix: string, items: DeleteBatchItem[]): void;
clear(): void;
};
export function browserRecursiveDeletePersistence(
storage: Storage
): RecursiveDeletePersistence {
return {
read() {
const encoded = storage.getItem(recursiveDeleteStorageKey);
const saved = readPendingDelete(encoded);
if (encoded !== null && saved === null) {
storage.removeItem(recursiveDeleteStorageKey);
}
return saved;
},
load(root, prefix) {
const encoded = storage.getItem(recursiveDeleteStorageKey);
const items = decodePendingDelete(encoded, root, prefix);
if (encoded !== null && items.length === 0) {
storage.removeItem(recursiveDeleteStorageKey);
}
return items;
},
save(root, prefix, items) {
storage.setItem(
recursiveDeleteStorageKey,
encodePendingDelete(root, prefix, items)
);
},
clear() {
storage.removeItem(recursiveDeleteStorageKey);
}
};
}
export function defaultRecursiveDeletePersistence(): RecursiveDeletePersistence {
return browserRecursiveDeletePersistence(globalThis.localStorage);
}

View file

@ -0,0 +1,89 @@
import type {
DeletePlanResponse,
ProductEndpoint,
Root,
SessionResponse
} from '../generated/contract.ts';
import { unfinishedDeleteItems, type PendingDelete } from '../mutations.ts';
import { requireValue } from '../transport.ts';
import type { RecursiveDeletePersistence } from './recursive-delete-persistence.ts';
export type RecursiveDeleteResult = {
deleted: number;
pages: number;
};
export class RecursiveDeletePageLimitError extends Error {
readonly pages: number;
constructor(pages: number) {
super(`Recursive delete stopped after ${pages} pages; run it again to continue.`);
this.name = 'RecursiveDeletePageLimitError';
this.pages = pages;
}
}
export function savedRecursiveDelete(
persistence: RecursiveDeletePersistence
): PendingDelete | null {
return persistence.read();
}
export async function runRecursiveDelete(
client: ProductEndpoint,
persistence: RecursiveDeletePersistence,
session: SessionResponse,
root: Root,
prefix: string,
nextOperationId: () => string,
pageLimit = 100
): Promise<RecursiveDeleteResult> {
let deleted = 0;
let completedPages = 0;
let continuation: string | null = null;
for (let page = 0; page < pageLimit; page += 1) {
let items = persistence.load(root, prefix);
let nextContinuation: string | null = null;
if (items.length === 0) {
const plan: DeletePlanResponse = requireValue(
await client.deletePlan({
root,
path: prefix,
...(continuation === null ? {} : { continuation })
})
);
if (plan.paths.length === 0) {
if (plan.continuation !== null) {
continuation = plan.continuation;
continue;
}
persistence.clear();
return { deleted, pages: completedPages };
}
nextContinuation = plan.continuation;
items = plan.paths.map((path) => ({
path,
operation_id: nextOperationId()
}));
persistence.save(root, prefix, items);
}
const response = requireValue(
await client.deleteBatch({
body: { root, csrf: session.csrf, items }
})
);
const unfinished = unfinishedDeleteItems(response.results, nextOperationId);
deleted += response.results.length - unfinished.length;
if (unfinished.length > 0) {
persistence.save(root, prefix, unfinished);
throw new Error(
`${unfinished.length} objects remain. Pending operations keep their IDs; terminal failures restart with new IDs.`
);
}
persistence.clear();
completedPages += 1;
continuation = nextContinuation;
}
throw new RecursiveDeletePageLimitError(pageLimit);
}

View file

@ -0,0 +1,73 @@
import type {
ProductEndpoint,
Root,
SessionResponse
} from '../generated/contract.ts';
import {
captureOperation,
mutationFollowup,
type CapturedOperation,
type MutationFollowup
} from '../mutations.ts';
import { requireValue } from '../transport.ts';
export type SingleDeleteState = {
pending: CapturedOperation | null;
followup: MutationFollowup;
};
export function initialSingleDeleteState(): SingleDeleteState {
return { pending: null, followup: 'replay' };
}
export function captureSingleDelete(
state: SingleDeleteState,
root: Root,
path: string,
operationId: string
): void {
state.pending = captureOperation(root, path, operationId);
state.followup = 'replay';
}
export function prepareSingleDeleteRetry(
state: SingleDeleteState,
operationId: string
): CapturedOperation | null {
if (state.pending === null) return null;
if (state.followup === 'restart') {
state.pending.operationId = operationId;
state.followup = 'replay';
}
return state.pending;
}
export async function executeSingleDelete(
client: ProductEndpoint,
session: SessionResponse,
state: SingleDeleteState
): Promise<string> {
const pending = state.pending;
if (pending === null) throw new Error('No delete is pending.');
const response = requireValue(
await client.delete({
body: {
root: pending.root,
path: pending.path,
operation_id: pending.operationId,
csrf: session.csrf
}
})
);
state.followup = mutationFollowup(response.result);
if (state.followup !== 'complete') {
throw new Error(
state.followup === 'replay'
? 'Delete is pending. Retry checks the same operation.'
: `Delete is ${response.result}. Start a new delete operation.`
);
}
const path = pending.path;
state.pending = null;
return path;
}

View file

@ -0,0 +1,6 @@
export async function uploadContentSha256(file: File): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0')
).join('');
}

View file

@ -0,0 +1,84 @@
import type {
ProductEndpoint,
Root,
SessionResponse
} from '../generated/contract.ts';
import {
captureOperation,
mutationFollowup,
type CapturedOperation,
type MutationFollowup
} from '../mutations.ts';
import { requireValue } from '../transport.ts';
export type PendingUpload = CapturedOperation & {
file: File;
};
export type UploadState = {
pending: PendingUpload | null;
followup: MutationFollowup;
};
export function initialUploadState(): UploadState {
return { pending: null, followup: 'replay' };
}
export function captureUpload(
state: UploadState,
root: Root,
path: string,
file: File,
operationId: string
): void {
state.pending = {
file,
...captureOperation(root, path, operationId)
};
state.followup = 'replay';
}
export function prepareUploadRetry(
state: UploadState,
operationId: string
): PendingUpload | null {
if (state.pending === null) return null;
if (state.followup === 'restart') {
state.pending.operationId = operationId;
state.followup = 'replay';
}
return state.pending;
}
export async function executeUpload(
client: ProductEndpoint,
session: SessionResponse,
state: UploadState,
digest: (file: File) => Promise<string>
): Promise<string> {
const pending = state.pending;
if (pending === null) throw new Error('No upload is pending.');
const contentSha256 = await digest(pending.file);
const response = requireValue(
await client.upload({
root: pending.root,
path: pending.path,
operationId: pending.operationId,
declaredSize: pending.file.size,
contentSha256,
csrfToken: session.csrf,
body: pending.file
})
);
state.followup = mutationFollowup(response.result);
if (state.followup !== 'complete') {
throw new Error(
state.followup === 'replay'
? 'Upload is pending. Retry checks the same operation.'
: `Upload is ${response.result}. Start a complete replacement upload.`
);
}
const path = pending.path;
state.pending = null;
return path;
}

View file

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
test('Svelte components own neither HTTP nor browser persistence policy', async () => {
const sources = await sourceFiles('src');
const svelte = sources.filter((file) => file.endsWith('.svelte'));
for (const file of svelte) {
const source = await readFile(file, 'utf8');
assert.doesNotMatch(source, /\bfetch\s*\(/, file);
assert.doesNotMatch(source, /\blocalStorage\b/, file);
assert.doesNotMatch(source, /\bon:/, file);
assert.doesNotMatch(source, /\$:/, file);
}
const storageOwners = [];
for (const file of sources) {
const source = await readFile(file, 'utf8');
if (source.includes('nuthatch-recursive-delete')) storageOwners.push(file);
}
assert.deepEqual(storageOwners, [
path.resolve('src/workflows/recursive-delete-persistence.ts')
]);
});
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map((entry) => {
const target = path.resolve(directory, entry.name);
return entry.isDirectory() ? sourceFiles(target) : [target];
})
)
).flat();
}

Some files were not shown because too many files have changed in this diff Show more