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

+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import "./layout.css";
import { ModeWatcher, mode, toggleMode } from "mode-watcher";
import { onMount } from "svelte";
import { page } from "$app/state";
import { requireSession, session, signOut } from "$lib/session.svelte";
let { children } = $props();
const onLogin = $derived(page.url.pathname === "/login");
onMount(() => {
if (!onLogin) {
requireSession();
}
});
</script>
<ModeWatcher />
{#if onLogin}
{@render children()}
{:else if session.signedIn}
<header class="bar">
<a class="wordmark" href="/">lodge</a>
<nav aria-label="Sections">
<a aria-current={page.url.pathname === "/" ? "page" : undefined} href="/"
>Repositories</a
>
<a
aria-current={page.url.pathname === "/machines" ? "page" : undefined}
href="/machines"
>Machines</a
>
</nav>
<div class="tools">
<button class="plain" onclick={toggleMode} type="button">
{mode.current === "dark" ? "Day print" : "Night print"}
</button>
<button class="plain" onclick={signOut} type="button">Sign out</button>
</div>
</header>
{@render children()}
{/if}
<style>
.bar {
display: flex;
gap: 1.5rem;
align-items: center;
height: 3rem;
padding: 0 1rem;
border-bottom: 1px solid var(--rule);
}
.wordmark {
font-size: 1.25rem;
font-weight: 800;
font-stretch: 68%;
letter-spacing: 0.01em;
text-decoration: none;
}
nav {
display: flex;
gap: 1.25rem;
}
nav a {
text-decoration-color: transparent;
}
nav a[aria-current="page"] {
text-decoration-thickness: 2px;
text-decoration-color: var(--red);
}
.tools {
display: flex;
gap: 1.25rem;
margin-left: auto;
}
.plain {
padding: 0.25rem 0;
color: var(--ink-2);
background: none;
border: 0;
transition: color 150ms var(--ease);
}
.plain:hover {
color: var(--ink);
}
@media (max-width: 40rem) {
.bar {
gap: 1rem;
}
.tools {
gap: 0.875rem;
font-size: 0.875rem;
}
nav a:first-child {
display: none;
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "$lib/api/client";
import {
ago,
exact,
lithology,
type Machine,
type Repo,
type Revision,
} from "$lib/lodge";
import { describe } from "$lib/session.svelte";
const CORE_BEDS = 60;
const KEY_LENGTH = 7;
let repos = $state<Repo[] | null>(null);
let machines: string[] = $state([]);
let cores: Record<string, Revision[]> = $state({});
let problem = $state("");
const load = async () => {
try {
const [list, known] = await Promise.all([
api.get<Repo[]>("/repos"),
api.get<Machine[]>("/machines"),
]);
repos = list;
machines = known.map((machine) => machine.name);
await Promise.all(
list.map(async (repo) => {
const revisions = await api.get<Revision[]>(
`/repos/${repo.key}/revisions`
);
cores[repo.key] = revisions.slice(0, CORE_BEDS);
})
);
} catch (error) {
problem = describe(error);
}
};
onMount(() => {
load();
const events = api.events();
events.onmessage = load;
return () => events.close();
});
</script>
<svelte:head>
<title>Repositories · lodge</title>
</svelte:head>
<main>
<h1>Repositories</h1>
{#if problem}
<p class="problem" role="alert">{problem}</p>
{/if}
{#if repos === null && !problem}
<div class="skeleton row-skeleton"></div>
<div class="skeleton row-skeleton"></div>
{:else if repos?.length === 0}
<div class="empty">
<p>
No repository has been logged yet. Connect a machine, then open any git
repository on it: lodge notices the repository on the next checkout, or
right away with <span class="mono">lodge add</span>.
</p>
<a class="act" href="/machines">Connect a machine</a>
</div>
{:else if repos}
<ul>
{#each repos as repo (repo.key)}
<li>
<a href="/r/{repo.key}">
<span class="name">{repo.name}</span>
<span class="facts">
<span class="mono" title={repo.key}
>{repo.key.slice(0, KEY_LENGTH)}</span
>
<span class="stamp">{repo.seq}</span>
{repo.seq === 1 ? "revision" : "revisions"}, last
<time
datetime={new Date(repo.time).toISOString()}
title={exact(repo.time)}
>{ago(repo.time)}</time
>
{#if cores[repo.key]?.[0]}
by {cores[repo.key][0].machine}
{/if}
</span>
<span aria-hidden="true" class="core">
{#each cores[repo.key] ?? [] as rev (rev.seq)}
<i
class={lithology(rev.machine, machines)}
style:flex-grow={Math.min(rev.changes.length, 6)}
></i>
{/each}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</main>
<style>
main {
max-width: 72rem;
padding: 3rem 1.5rem 6rem;
margin: 0 auto;
}
h1 {
margin-bottom: 1.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--ink-2);
}
li a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 2rem;
padding: 1.25rem 0 2rem;
text-decoration: none;
border-top: 1px solid var(--ink);
}
li a:hover {
color: inherit;
}
.name {
font-size: clamp(2.25rem, 6vw, 4rem);
font-weight: 700;
font-stretch: 66%;
line-height: 1;
overflow-wrap: anywhere;
transition: color 150ms var(--ease);
}
li a:hover .name {
color: var(--green);
}
.facts {
align-self: end;
font-size: 0.9375rem;
color: var(--ink-2);
text-align: right;
}
.facts .stamp {
font-size: 1.125rem;
color: var(--ink);
}
.core {
display: flex;
grid-column: 1 / -1;
height: 1.75rem;
margin-top: 0.75rem;
overflow: hidden;
border: 1px solid var(--ink);
}
.core i {
flex: 1 0 0.5rem;
max-width: 6rem;
border-right: 1px solid var(--ink);
transition: background-color 150ms var(--ease);
}
li a:hover .core i {
--l: var(--green);
}
.empty {
display: grid;
gap: 1.25rem;
justify-items: start;
max-width: 38rem;
padding-top: 1.25rem;
border-top: 1px solid var(--ink);
}
.row-skeleton {
height: 7rem;
margin-bottom: 1rem;
}
@media (max-width: 40rem) {
main {
padding: 2rem 1rem 4rem;
}
li a {
grid-template-columns: minmax(0, 1fr);
}
.facts {
text-align: left;
}
}
</style>
+264
View File
@@ -0,0 +1,264 @@
@import "tailwindcss";
@import "@fontsource-variable/archivo/wdth.css";
@import "@fontsource-variable/jetbrains-mono/wght.css";
@custom-variant dark (&:is(.dark *));
:root {
--paper: #eef0e6;
--paper-2: #e3e7d8;
--ink: #15211d;
--ink-2: #46544d;
--rule: rgb(21 33 29 / 0.2);
--grid: rgb(36 105 79 / 0.13);
--grid-strong: rgb(36 105 79 / 0.3);
--green: #24694f;
--green-wash: rgb(36 105 79 / 0.12);
--red: #b3341f;
--red-wash: rgb(179 52 31 / 0.11);
--ochre: #c9a24a;
--sans: "Archivo Variable", system-ui, sans-serif;
--mono: "JetBrains Mono Variable", ui-monospace, monospace;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
color-scheme: light;
}
.dark {
--paper: #162026;
--paper-2: #1d2a31;
--ink: #e6eadc;
--ink-2: #a5b2a8;
--rule: rgb(230 234 220 / 0.2);
--grid: rgb(111 199 163 / 0.09);
--grid-strong: rgb(111 199 163 / 0.24);
--green: #74caa6;
--green-wash: rgb(116 202 166 / 0.14);
--red: #f08a74;
--red-wash: rgb(240 138 116 / 0.14);
--ochre: #d9b45e;
color-scheme: dark;
}
html {
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 100%;
line-height: 1.5;
scrollbar-color: var(--ink-2) transparent;
scrollbar-width: thin;
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100dvh;
caret-color: var(--red);
}
::selection {
color: var(--paper);
background: var(--green);
}
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
a {
color: inherit;
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--rule);
text-underline-offset: 0.22em;
transition:
text-decoration-color 150ms var(--ease),
color 150ms var(--ease);
}
a:hover {
color: var(--green);
text-decoration-color: currentColor;
}
button {
font: inherit;
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.5;
}
.stamp {
font-weight: 600;
font-stretch: 68%;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.label {
font-size: 0.75rem;
font-weight: 500;
font-stretch: 80%;
color: var(--ink-2);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mono {
font-family: var(--mono);
font-size: 0.8125em;
font-variant-ligatures: none;
}
.act {
padding: 0.125rem 0;
font-weight: 600;
color: var(--green);
background: none;
border: 0;
border-bottom: 1px solid currentColor;
transition:
color 150ms var(--ease),
background-color 150ms var(--ease);
}
.act:hover:not(:disabled) {
background: var(--green-wash);
}
.act.danger {
color: var(--red);
}
.act.danger:hover:not(:disabled) {
background: var(--red-wash);
}
.act.solid {
padding: 0.625rem 1rem;
color: var(--paper);
background: var(--ink);
border: 0;
}
.act.solid:hover:not(:disabled) {
background: var(--green);
}
.act.solid.danger {
color: var(--paper);
background: var(--red);
}
.field {
width: 100%;
padding: 0.5rem 0;
font: inherit;
color: var(--ink);
background: transparent;
border: 0;
border-bottom: 2px solid var(--ink);
border-radius: 0;
}
.field::placeholder {
color: var(--ink-2);
}
.field:focus-visible {
outline: none;
border-bottom-color: var(--red);
}
.problem {
font-weight: 500;
color: var(--red);
}
.lith {
--l: var(--ink);
position: relative;
background-color: var(--paper);
}
.lith::after {
position: absolute;
inset: 0;
content: "";
background: var(--l);
}
.lith-0::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Ccircle cx='3' cy='3' r='0.9' fill='black' stroke='none'/%3E%3C/svg%3E")
repeat;
}
.lith-1::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M-1 9 9 -1M-1 1 1 -1M7 9 9 7'/%3E%3C/svg%3E")
repeat;
}
.lith-2::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 2.5h7M6 7.5h6M0 7.5h1'/%3E%3C/svg%3E")
repeat;
}
.lith-3::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 .5h12M0 4.5h12M.5 0v4M6.5 4v4'/%3E%3C/svg%3E")
repeat;
}
.lith-4::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M4 2v4M2 4h4'/%3E%3C/svg%3E")
repeat;
}
.lith-5::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 3q3 -3 6 0t6 0'/%3E%3C/svg%3E")
repeat;
}
.lith-6::after {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='6' fill='none' stroke='black' stroke-width='1'%3E%3Cpath d='M0 5l4 -4l4 4'/%3E%3C/svg%3E")
repeat;
}
.lith-web {
background-color: var(--ochre);
}
.lith-web::after,
.lith-gone::after {
display: none;
}
.lith-gone {
outline: 1px dashed var(--rule);
outline-offset: -1px;
}
.skeleton {
color: transparent;
background: var(--paper-2);
}
@keyframes settle {
from {
clip-path: inset(0 0 100% 0);
}
to {
clip-path: inset(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 1ms !important;
animation-duration: 1ms !important;
}
}
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { ApiError, api } from "$lib/api/client";
import { describe, session } from "$lib/session.svelte";
let password = $state("");
let busy = $state(false);
let problem = $state("");
const submit = async (event: SubmitEvent) => {
event.preventDefault();
busy = true;
problem = "";
try {
await api.post("/session", { password });
session.signedIn = true;
await goto("/");
} catch (error) {
problem =
error instanceof ApiError && error.status === 401
? "That is not the admin token. Use the ADMIN_TOKEN value from the server's .env file."
: describe(error);
} finally {
busy = false;
}
};
</script>
<svelte:head>
<title>Sign in · lodge</title>
</svelte:head>
<main>
<div aria-hidden="true" class="core lith lith-1"></div>
<form onsubmit={submit}>
<h1>lodge</h1>
<p class="lede">
Agent notes for every repository, at every depth of their history.
</p>
<label class="label" for="password">Admin token</label>
<input
autocomplete="current-password"
class="field mono"
id="password"
required
type="password"
bind:value={password}
>
{#if problem}
<p class="problem" role="alert">{problem}</p>
{/if}
<button class="act solid" disabled={busy} type="submit">
{busy ? "Opening…" : "Open the log"}
</button>
</form>
</main>
<style>
main {
display: grid;
grid-template-columns: clamp(3rem, 12vw, 9rem) minmax(0, 26rem);
gap: clamp(1.5rem, 5vw, 4rem);
min-height: 100dvh;
padding-right: 1.5rem;
}
.core {
--l: var(--green);
border-right: 1px solid var(--rule);
}
form {
display: grid;
gap: 0.5rem;
align-self: center;
justify-items: start;
}
h1 {
font-size: clamp(3.5rem, 10vw, 6rem);
font-weight: 800;
font-stretch: 62%;
line-height: 0.9;
letter-spacing: -0.01em;
}
.lede {
max-width: 22rem;
margin-bottom: 2.5rem;
color: var(--ink-2);
}
.act {
margin-top: 1.5rem;
}
</style>
+307
View File
@@ -0,0 +1,307 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "$lib/api/client";
import { ago, type Code, exact, lithology, type Machine } from "$lib/lodge";
import { describe } from "$lib/session.svelte";
const SECOND = 1000;
const MINUTE = 60;
let machines = $state<Machine[] | null>(null);
let problem = $state("");
let code = $state<Code | null>(null);
let now = $state(Date.now());
let copied = $state(false);
let revoking = $state(0);
let busy = $state(false);
const names = $derived(machines?.map((machine) => machine.name) ?? []);
const remaining = $derived(
code ? Math.max(0, Math.round((code.expires_at - now) / SECOND)) : 0
);
const countdown = $derived(
`${Math.floor(remaining / MINUTE)}:${String(remaining % MINUTE).padStart(2, "0")}`
);
const load = async () => {
try {
machines = await api.get<Machine[]>("/machines");
} catch (error) {
problem = describe(error);
}
};
const invite = async () => {
busy = true;
copied = false;
try {
code = await api.post<Code>("/codes");
problem = "";
} catch (error) {
problem = describe(error);
} finally {
busy = false;
}
};
const copy = async () => {
if (code) {
await navigator.clipboard.writeText(code.command);
copied = true;
}
};
const revoke = async (id: number) => {
busy = true;
try {
await api.del(`/machines/${id}`);
revoking = 0;
await load();
} catch (error) {
problem = describe(error);
} finally {
busy = false;
}
};
onMount(() => {
load();
const timer = setInterval(() => {
now = Date.now();
if (code && remaining === 0) {
code = null;
load();
}
}, SECOND);
const events = api.events();
events.onmessage = load;
return () => {
clearInterval(timer);
events.close();
};
});
</script>
<svelte:head>
<title>Machines · lodge</title>
</svelte:head>
<main>
<h1>Machines</h1>
<p class="lede">
The legend of the log. Each machine owns a hatch, and every bed it writes is
drawn with it.
</p>
{#if problem}
<p class="problem" role="alert">{problem}</p>
{/if}
<section class="invite">
{#if code && remaining > 0}
<p>
Run this on the new machine. It installs the client, signs in and starts
the daemon. The link works once and expires in
<span class="stamp">{countdown}</span>.
</p>
<pre class="mono command">{code.command}</pre>
<div class="row">
<button class="act solid" onclick={copy} type="button">
{copied ? "Copied" : "Copy command"}
</button>
<button class="act" disabled={busy} onclick={invite} type="button">
New link
</button>
</div>
{:else}
<button class="act solid" disabled={busy} onclick={invite} type="button">
Connect a machine
</button>
{/if}
</section>
{#if machines === null && !problem}
<div class="skeleton row-skeleton"></div>
{:else if machines?.length === 0}
<p class="empty">
No machine is connected yet. The command above works on macOS and Linux,
on Intel and ARM.
</p>
{:else if machines}
<ul>
{#each machines as machine (machine.id)}
<li>
<span
aria-hidden="true"
class="swatch {lithology(machine.name, names)}"
></span>
<span class="name stamp">{machine.name}</span>
<span class="facts">
<span class="stamp">{machine.revisions}</span>
{machine.revisions === 1 ? "revision" : "revisions"}, last push
{ago(machine.last_push)}. Connected
<time
datetime={new Date(machine.created_at).toISOString()}
title={exact(machine.created_at)}
>{ago(machine.created_at)}</time
>.
</span>
<span class="action">
{#if revoking === machine.id}
<span class="confirm">
Its token stops working at once; files on it stay.
<button
class="act danger"
disabled={busy}
onclick={() => revoke(machine.id)}
type="button"
>
Revoke {machine.name}
</button>
<button
class="act"
onclick={() => {
revoking = 0;
}}
type="button"
>
Keep
</button>
</span>
{:else}
<button
class="act danger"
onclick={() => {
revoking = machine.id;
}}
type="button"
>
Revoke
</button>
{/if}
</span>
</li>
{/each}
<li>
<span aria-hidden="true" class="swatch lith lith-web"></span>
<span class="name stamp">web</span>
<span class="facts">Versions restored from this page.</span>
</li>
</ul>
{/if}
</main>
<style>
main {
max-width: 72rem;
padding: 3rem 1.5rem 6rem;
margin: 0 auto;
}
h1 {
font-size: clamp(2.25rem, 6vw, 4rem);
font-weight: 700;
font-stretch: 66%;
line-height: 1;
}
.lede {
max-width: 34rem;
margin-top: 0.75rem;
color: var(--ink-2);
}
.invite {
display: grid;
gap: 1rem;
justify-items: start;
max-width: 46rem;
margin: 2.5rem 0 3.5rem;
}
.command {
max-width: 100%;
padding: 1rem 1.25rem;
overflow-x: auto;
user-select: all;
background: var(--paper-2);
border-left: 1px solid var(--ink);
}
.row {
display: flex;
gap: 1.5rem;
align-items: baseline;
}
li {
display: grid;
grid-template-columns: 6rem minmax(8rem, 14rem) minmax(0, 1fr) auto;
gap: 0.25rem 1.5rem;
align-items: center;
padding: 1rem 0;
border-top: 1px solid var(--ink);
}
.swatch {
height: 3rem;
border: 1px solid var(--ink);
}
.name {
font-size: 1.75rem;
overflow-wrap: anywhere;
}
.facts {
font-size: 0.9375rem;
color: var(--ink-2);
}
.facts .stamp {
color: var(--ink);
}
.confirm {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
justify-content: flex-end;
max-width: 22rem;
font-size: 0.875rem;
}
.empty {
max-width: 34rem;
color: var(--ink-2);
}
.row-skeleton {
height: 5rem;
}
@media (max-width: 48rem) {
main {
padding: 2rem 1rem 4rem;
}
li {
grid-template-columns: 4rem minmax(0, 1fr);
}
.swatch {
grid-row: span 3;
height: 100%;
min-height: 4rem;
}
.facts,
.action {
grid-column: 2;
}
.confirm {
justify-content: flex-start;
}
}
</style>
+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>