feat: lodge server, client and admin

This commit is contained in:
hh committed 2026-09-19 23:33:24 +02:00
commit 95fc50e2c2
84 files changed
+7625

No files matched your search

+815
View File
@@ -0,0 +1,815 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import Note from "$lib/components/Note.svelte";
import StripLog from "$lib/components/StripLog.svelte";
import {
ago,
blob,
changeKind,
exact,
fileId,
type Machine,
type Repo,
type RepoState,
type Revision,
SHARED,
shortSha,
spaceName,
text,
} from "$lib/lodge";
import { describe } from "$lib/session.svelte";
interface Opened {
before: string | null;
now: string | null;
}
const KEY_LENGTH = 12;
const TRACK_TICKS = 100;
const key = $derived(page.params.key ?? "");
const query = $derived(page.url.searchParams);
const space = $derived(query.get("space") ?? "");
const path = $derived(query.get("path") ?? "");
const requestedView = $derived(query.get("view") ?? "read");
let revisions: Revision[] = $state([]);
let machines: string[] = $state([]);
let name = $state("");
let loaded = $state(false);
let problem = $state("");
let fresh = $state(0);
let repoState = $state<RepoState | null>(null);
let opened = $state<Opened | null>(null);
let confirming = $state("");
let typedName = $state("");
let busy = $state(false);
const states = new Map<number, Promise<RepoState>>();
const latest = $derived(revisions[0]?.seq ?? 0);
const at = $derived(Number(query.get("at")) || latest);
const bed = $derived(revisions.find((rev) => rev.seq === at));
const markdown = $derived(path.toLowerCase().endsWith(".md"));
const view = $derived(
!markdown && requestedView === "read" ? "source" : requestedView
);
const history = $derived.by(() => {
const byFile = new Map<string, Revision[]>();
for (const rev of revisions) {
for (const change of rev.changes) {
const id = fileId(change.space, change.path);
byFile.set(id, [...(byFile.get(id) ?? []), rev]);
}
}
return byFile;
});
const fileHistory = $derived(history.get(fileId(space, path)) ?? []);
const touched = $derived(
path ? new Set(fileHistory.map((rev) => rev.seq)) : null
);
const entry = $derived(repoState?.spaces[space]?.[path]);
const written = $derived(fileHistory.find((rev) => rev.seq <= at));
const writtenChange = $derived(
written?.changes.find(
(change) => change.space === space && change.path === path
)
);
const surfaceHash = $derived(
fileHistory[0]?.changes.find(
(change) => change.space === space && change.path === path
)?.hash ?? ""
);
const restorable = $derived(Boolean(entry) && entry?.hash !== surfaceHash);
const spaces = $derived(
Object.keys(repoState?.spaces ?? {}).sort((a, b) => {
if (a === SHARED || b === SHARED) {
return a === SHARED ? -1 : 1;
}
return a.localeCompare(b);
})
);
const href = (patch: Record<string, string | number>) => {
const next = new URLSearchParams(query);
for (const [param, value] of Object.entries(patch)) {
if (value === "" || (param === "at" && value === latest)) {
next.delete(param);
} else {
next.set(param, String(value));
}
}
const search = next.toString();
return search ? `?${search}` : page.url.pathname;
};
const select = (seq: number, scrubbing: boolean) => {
confirming = "";
goto(href({ at: seq }), {
keepFocus: true,
noScroll: true,
replaceState: scrubbing,
});
};
const ticks = (space_: string, path_: string) => {
const revs = history.get(fileId(space_, path_)) ?? [];
const oldest = revisions.at(-1)?.seq ?? 0;
const span = Math.max(1, latest - oldest);
return revs.map((rev) => ({
above: rev.seq > at,
left: ((rev.seq - oldest) / span) * TRACK_TICKS,
}));
};
const load = async () => {
try {
const [revs, repos, known] = await Promise.all([
api.get<Revision[]>(`/repos/${key}/revisions`),
api.get<Repo[]>("/repos"),
api.get<Machine[]>("/machines"),
]);
revisions = revs;
name = repos.find((repo) => repo.key === key)?.name ?? "";
machines = known.map((machine) => machine.name);
problem = "";
} catch (error) {
problem = describe(error);
} finally {
loaded = true;
}
};
const stateAt = (seq: number) => {
let pending = states.get(seq);
if (!pending) {
pending = api.get<RepoState>(`/repos/${key}/state?at=${seq}`);
states.set(seq, pending);
}
return pending;
};
const readText = async (hash: string | undefined) =>
hash ? text(await blob(hash)) : "";
$effect(() => {
if (!at) {
return;
}
let stale = false;
stateAt(at)
.then((value) => {
if (!stale) {
repoState = value;
}
})
.catch((error) => {
states.delete(at);
problem = describe(error);
});
return () => {
stale = true;
};
});
$effect(() => {
const hash = entry?.hash;
const base = writtenChange?.base;
if (!hash) {
opened = null;
return;
}
let stale = false;
Promise.all([readText(hash), readText(base)])
.then(([now, before]) => {
if (!stale) {
opened = { before, now };
}
})
.catch((error) => {
problem = describe(error);
});
return () => {
stale = true;
};
});
const restore = async () => {
if (!(entry && revisions[0])) {
return;
}
busy = true;
try {
await api.post(`/repos/${key}/revisions`, {
changes: [{ base: surfaceHash, hash: entry.hash, path, space }],
main_branch: revisions[0].main_branch,
main_sha: revisions[0].main_sha,
name,
});
confirming = "";
await load();
await goto(href({ at: "" }), { noScroll: true });
} catch (error) {
problem = describe(error);
} finally {
busy = false;
}
};
const remove = async () => {
busy = true;
try {
await api.del(`/repos/${key}`);
await goto("/");
} catch (error) {
problem = describe(error);
busy = false;
}
};
onMount(() => {
load();
const events = api.events();
events.onmessage = async (message) => {
const event = JSON.parse(message.data) as { repo: string; seq: number };
if (event.repo === key && event.seq) {
await load();
fresh = event.seq;
}
};
return () => events.close();
});
</script>
<svelte:head>
<title>
{path ? `${path.split("/").at(-1)} · ` : ""}{name || "repository"}
· lodge
</title>
</svelte:head>
{#snippet slashed(value: string)}
{#each value.split("/") as part, index (index)}
{index ? "/" : ""}<wbr>{part}
{/each}
{/snippet}
<div class="sheet" class:reading={Boolean(path)}>
<aside class="log">
<dl class="titleblock">
<div class="wide">
<dt class="label">Repository</dt>
<dd class="name stamp">{name || "…"}</dd>
</div>
<div>
<dt class="label">Depth</dt>
<dd class="stamp">{at} / {latest}</dd>
</div>
<div>
<dt class="label">Root</dt>
<dd class="mono" title={key}>{key.slice(0, KEY_LENGTH)}</dd>
</div>
</dl>
{#if loaded}
<StripLog
{at}
{fresh}
{machines}
onselect={select}
{revisions}
{touched}
/>
{:else}
<div class="skeleton column-skeleton"></div>
{/if}
</aside>
<section aria-label="Files at this depth" class="files">
{#if problem}
<p class="problem" role="alert">{problem}</p>
{/if}
{#if bed}
<div class="bedinfo">
<h2 class="stamp">
Depth {bed.seq}{bed.seq === latest ? ", the surface" : ""}
</h2>
<p>
<strong>{bed.machine}</strong>
<time
datetime={new Date(bed.time).toISOString()}
title={exact(bed.time)}
>{ago(bed.time)}</time
>
on {bed.main_branch || "detached"}
<span class="mono">{shortSha(bed.main_sha)}</span>
</p>
<ul>
{#each bed.changes as change (fileId(change.space, change.path))}
<li class={changeKind(change)}>
<span class="kind">{changeKind(change)}</span>
{#if change.hash}
<a
href={href({ path: change.path, space: change.space, view: "changes" })}
>{@render slashed(change.path)}</a
>
{:else}
<span>{@render slashed(change.path)}</span>
{/if}
</li>
{/each}
</ul>
{#if bed.seq !== latest}
<a class="surface" href={href({ at: "" })}>Back to the surface</a>
{/if}
</div>
{/if}
{#each spaces as group (group)}
<h3 class="label space">
{group === SHARED ? "Shared notes" : `Branch ${spaceName(group)}`}
</h3>
<ul class="rows">
{#each Object.entries(repoState?.spaces[group] ?? {}).sort( ([a], [b]) => a.localeCompare(b) ) as [ file, info ] (file)}
<li>
<a
aria-current={group === space && file === path ? "true" : undefined}
href={href({ path: file, space: group, view: "" })}
>
<span class="file">
<span class="dir"
>{@render slashed(file.slice(0, file.lastIndexOf("/") + 1))}</span
>{file.slice(file.lastIndexOf("/") + 1)}
</span>
<span aria-hidden="true" class="track">
{#each ticks(group, file) as tick (tick.left)}
<i style:left="{tick.left}%" class:above={tick.above}></i>
{/each}
</span>
<span class="when">{ago(info.time)}</span>
</a>
</li>
{/each}
</ul>
{:else}
{#if loaded && !problem}
<p class="empty">
{latest
? "Nothing had been written yet at this depth. Drag the column up towards the surface."
: "This repository has no revisions yet. Write a file under docs/agents on any connected machine and it lands here within seconds."}
</p>
{/if}
{/each}
<details class="removal">
<summary>Delete this repository</summary>
<p>
Erases all {latest} revisions from the server. Files on your machines
stay where they are, and a machine that still syncs this repository
starts a new history. Type <strong>{name}</strong> to confirm.
</p>
<input
aria-label="Repository name"
class="field"
placeholder={name}
bind:value={typedName}
>
<button
class="act solid danger"
disabled={busy || typedName !== name || !name}
onclick={remove}
type="button"
>
Delete with all history
</button>
</details>
</section>
<main class="reader">
{#if path}
<a class="back" href={href({ path: "", space: "", view: "" })}>Files</a>
<h1>{path.split("/").at(-1)}</h1>
<p class="meta">
{space === SHARED ? "shared" : `branch ${spaceName(space)}`}
<span class="mono">{path}</span>
{#if written}
written at depth {written.seq} by {written.machine},
<time
datetime={new Date(written.time).toISOString()}
title={exact(written.time)}
>{ago(written.time)}</time
>, on
<span class="together"
>{written.main_branch || "detached"}
<span class="mono">{shortSha(written.main_sha)}</span></span
>
{/if}
</p>
{#if entry}
<div class="controls">
{#each [["read", "Read"], ["changes", "Changes"], ["source", "Source"]] as [ id, title ] (id)}
{#if markdown || id !== "read"}
<a
aria-current={view === id ? "true" : undefined}
href={href({ view: id })}
>{title}</a
>
{/if}
{/each}
{#if restorable}
{#if confirming === "restore"}
<span class="confirm">
Put depth {written?.seq} back on the surface?
<button
class="act"
disabled={busy}
onclick={restore}
type="button"
>
Restore
</button>
<button
class="act danger"
onclick={() => {
confirming = "";
}}
type="button"
>
Keep current
</button>
</span>
{:else}
<button
class="act"
onclick={() => {
confirming = "restore";
}}
type="button"
>
Restore this version
</button>
{/if}
{/if}
</div>
{#if opened === null}
<div class="skeleton text-skeleton"></div>
{:else if opened.now === null}
<p class="empty">
This file is binary, so there is nothing to read here. Fetch it with
<span class="mono">lodge show {at} {path}</span>.
</p>
{:else}
<Note before={opened.before} {markdown} now={opened.now} {view} />
{/if}
{:else if repoState}
<p class="empty">
{path.split("/").at(-1)}
does not exist at depth {at}.
{#if fileHistory.length}
It was last written at depth
<a href={href({ at: fileHistory[0].seq })}>{fileHistory[0].seq}</a>.
{/if}
</p>
{/if}
{:else}
<p class="empty hint">
Pick a file to pull its core from depth {at}. Drag along the column, or
use the arrow keys, to read the notes as they were at any revision.
</p>
{/if}
</main>
</div>
<style>
.sheet {
display: grid;
grid-template-columns: 17.5rem 20rem minmax(0, 1fr);
height: calc(100dvh - 3rem);
}
.log {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-right: 1px solid var(--ink);
}
.titleblock {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid var(--ink);
}
.titleblock div {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--rule);
}
.titleblock div + div + div {
border-left: 1px solid var(--rule);
}
.titleblock .wide {
grid-column: 1 / -1;
border-top: 0;
}
.name {
font-size: 1.75rem;
line-height: 1.1;
overflow-wrap: anywhere;
}
.column-skeleton {
height: 100%;
}
.files,
.reader {
padding: 1.5rem;
overflow-y: auto;
}
.files {
border-right: 1px solid var(--rule);
}
.bedinfo {
margin-bottom: 2rem;
}
.bedinfo h2 {
font-size: 1.375rem;
}
.bedinfo p,
.meta {
font-size: 0.875rem;
color: var(--ink-2);
}
.bedinfo strong {
color: var(--ink);
}
.bedinfo ul {
margin-top: 0.625rem;
font-size: 0.875rem;
}
.bedinfo li {
display: grid;
grid-template-columns: 4.25rem minmax(0, 1fr);
gap: 0.5rem;
padding: 0.125rem 0;
overflow-wrap: break-word;
}
.kind {
font-size: 0.75rem;
font-weight: 600;
font-stretch: 80%;
line-height: 1.3125rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.added .kind {
color: var(--green);
}
.deleted .kind,
.deleted span + span {
color: var(--red);
}
.deleted span + span {
text-decoration: line-through;
}
.surface {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.875rem;
}
.space {
padding-bottom: 0.25rem;
margin-top: 1.75rem;
border-bottom: 1px solid var(--ink);
}
.rows a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.125rem 0.75rem;
align-items: baseline;
padding: 0.5rem 0.375rem;
text-decoration: none;
border-bottom: 1px solid var(--rule);
transition: background-color 150ms var(--ease);
}
.rows a:hover {
color: inherit;
background: var(--green-wash);
}
.rows a[aria-current="true"] {
color: var(--paper);
background: var(--ink);
}
.file {
grid-column: 1 / -1;
font-weight: 600;
overflow-wrap: break-word;
}
.dir {
font-weight: 400;
opacity: 0.65;
}
.when {
font-size: 0.8125rem;
white-space: nowrap;
opacity: 0.7;
}
.track {
position: relative;
grid-row: 2;
grid-column: 1 / -1;
align-self: center;
height: 0.4375rem;
border-bottom: 1px solid currentColor;
opacity: 0.85;
}
.track i {
position: absolute;
bottom: 0;
width: 2px;
height: 100%;
background: currentColor;
}
.track i.above {
opacity: 0.3;
}
.empty {
max-width: 34rem;
color: var(--ink-2);
}
.hint {
margin-top: 20vh;
font-size: 1.125rem;
}
.removal {
margin-top: 4rem;
font-size: 0.875rem;
color: var(--ink-2);
}
.removal summary {
cursor: pointer;
}
.removal[open] {
color: var(--ink);
}
.removal p {
margin: 0.75rem 0;
}
.removal .act {
margin-top: 0.75rem;
}
.reader {
padding: 2rem clamp(1.5rem, 4vw, 4rem) 6rem;
}
.back {
display: none;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
font-stretch: 75%;
line-height: 1.05;
overflow-wrap: anywhere;
}
.meta {
margin-top: 0.5rem;
}
.together {
white-space: nowrap;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
align-items: baseline;
padding-bottom: 0.5rem;
margin: 1.5rem 0 2rem;
border-bottom: 1px solid var(--rule);
}
.controls a {
text-decoration-color: transparent;
}
.controls a[aria-current="true"] {
font-weight: 700;
text-decoration-thickness: 2px;
text-decoration-color: var(--red);
}
.controls > :is(button, .confirm) {
margin-left: auto;
}
.confirm {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
}
.text-skeleton {
max-width: 70ch;
height: 16rem;
}
@media (max-width: 72rem) {
.sheet {
grid-template-columns: 15rem 17rem minmax(0, 1fr);
}
}
@media (max-width: 56rem) {
.sheet {
grid-template-columns: 4.5rem minmax(0, 1fr);
}
.titleblock {
display: block;
}
.titleblock div:not(.wide) {
display: none;
}
.titleblock .wide {
padding: 0.375rem;
}
.titleblock .label {
display: none;
}
.name {
font-size: 0.8125rem;
}
.files,
.reader {
padding: 1.25rem 1rem 5rem;
}
.files {
border-right: 0;
}
.sheet.reading .files,
.sheet:not(.reading) .reader {
display: none;
}
.back {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.875rem;
}
h1 {
font-size: 1.75rem;
}
}
</style>