Split browser workflows from presentation
This commit is contained in:
parent
c9642f4067
commit
0c2d992224
25 changed files with 1683 additions and 854 deletions
1009
web/src/App.svelte
1009
web/src/App.svelte
File diff suppressed because it is too large
Load diff
342
web/src/app.css
Normal file
342
web/src/app.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/components/AppHeader.svelte
Normal file
21
web/src/components/AppHeader.svelte
Normal 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>
|
||||||
22
web/src/components/Breadcrumbs.svelte
Normal file
22
web/src/components/Breadcrumbs.svelte
Normal 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>
|
||||||
46
web/src/components/Feedback.svelte
Normal file
46
web/src/components/Feedback.svelte
Normal 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}
|
||||||
121
web/src/components/ObjectBrowser.svelte
Normal file
121
web/src/components/ObjectBrowser.svelte
Normal 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>
|
||||||
23
web/src/components/ResumeDelete.svelte
Normal file
23
web/src/components/ResumeDelete.svelte
Normal 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}
|
||||||
46
web/src/components/UploadPanel.svelte
Normal file
46
web/src/components/UploadPanel.svelte
Normal 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>
|
||||||
47
web/src/components/WorkspaceHeader.svelte
Normal file
47
web/src/components/WorkspaceHeader.svelte
Normal 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}
|
||||||
|
|
@ -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
40
web/src/presentation.ts
Normal 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';
|
||||||
|
}
|
||||||
|
|
@ -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
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
67
web/src/workflows/browse.ts
Normal file
67
web/src/workflows/browse.ts
Normal 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;
|
||||||
|
}
|
||||||
52
web/src/workflows/recursive-delete-persistence.ts
Normal file
52
web/src/workflows/recursive-delete-persistence.ts
Normal 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);
|
||||||
|
}
|
||||||
89
web/src/workflows/recursive-delete.ts
Normal file
89
web/src/workflows/recursive-delete.ts
Normal 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);
|
||||||
|
}
|
||||||
73
web/src/workflows/single-delete.ts
Normal file
73
web/src/workflows/single-delete.ts
Normal 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;
|
||||||
|
}
|
||||||
6
web/src/workflows/upload-identity.ts
Normal file
6
web/src/workflows/upload-identity.ts
Normal 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('');
|
||||||
|
}
|
||||||
84
web/src/workflows/upload.ts
Normal file
84
web/src/workflows/upload.ts
Normal 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;
|
||||||
|
}
|
||||||
37
web/test/browser-boundaries.test.mjs
Normal file
37
web/test/browser-boundaries.test.mjs
Normal 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
Loading…
Reference in a new issue