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

+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta content="scale" name="text-scale">
<link href="%sveltekit.assets%/favicon.svg" rel="icon" type="image/svg+xml">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import { env } from "$env/dynamic/public";
const BASE = env.PUBLIC_API_BASE_URL ?? "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
type Json = Record<string, unknown> | unknown[];
const send = async (
method: string,
path: string,
body?: Json
): Promise<Response> => {
const init: RequestInit = { credentials: "include", method };
if (body !== undefined) {
init.headers = { "Content-Type": "application/json" };
init.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, init);
if (!res.ok) {
const detail = (await res.text()).trim();
throw new ApiError(res.status, detail || res.statusText);
}
return res;
};
const request = async <T>(
method: string,
path: string,
body?: Json
): Promise<T> => {
const res = await send(method, path, body);
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
};
export const api = {
bytes: async (path: string) =>
new Uint8Array(await (await send("GET", path)).arrayBuffer()),
del: <T>(path: string) => request<T>("DELETE", path),
events: () => new EventSource(`${BASE}/events`, { withCredentials: true }),
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: Json) => request<T>("POST", path, body),
};
+253
View File
@@ -0,0 +1,253 @@
<script lang="ts">
import { diffLines } from "diff";
import DOMPurify from "dompurify";
import { marked } from "marked";
interface Props {
before: string | null;
markdown: boolean;
now: string;
view: string;
}
interface Row {
kind: "added" | "gap" | "removed" | "same";
text: string;
}
const { before, markdown, now, view }: Props = $props();
const CONTEXT = 3;
const TRAILING_NEWLINE = /\n$/;
const html = $derived(
markdown && view === "read"
? DOMPurify.sanitize(marked.parse(now, { async: false }))
: ""
);
const lines = (value: string) =>
value.replace(TRAILING_NEWLINE, "").split("\n");
const rows = $derived.by(() => {
const result: Row[] = [];
for (const part of diffLines(before ?? "", now)) {
const partLines = lines(part.value);
if (part.added || part.removed) {
const kind = part.added ? "added" : "removed";
result.push(...partLines.map((text): Row => ({ kind, text })));
continue;
}
if (partLines.length <= CONTEXT * 2 + 1) {
result.push(...partLines.map((text): Row => ({ kind: "same", text })));
continue;
}
const hidden = partLines.length - CONTEXT * 2;
result.push(
...partLines
.slice(0, CONTEXT)
.map((text): Row => ({ kind: "same", text })),
{ kind: "gap", text: `${hidden} unchanged lines` },
...partLines
.slice(-CONTEXT)
.map((text): Row => ({ kind: "same", text }))
);
}
return result;
});
</script>
{#if view === "changes"}
<ol class="diff mono">
{#each rows as row, index (index)}
<li class={row.kind}>
{#if row.kind === "removed"}
<del>{row.text || " "}</del>
{:else if row.kind === "added"}
<ins>{row.text || " "}</ins>
{:else}
{row.text || " "}
{/if}
</li>
{/each}
</ol>
{:else if html}
<article class="prose">
{@html html}
</article>
{:else}
<pre class="source mono">{now}</pre>
{/if}
<style>
.prose {
max-width: 70ch;
font-size: 1.0625rem;
line-height: 1.65;
overflow-wrap: break-word;
}
.prose :global(:is(h1, h2, h3, h4)) {
margin: 2.25em 0 0.6em;
font-weight: 700;
font-stretch: 85%;
line-height: 1.2;
text-wrap: balance;
}
.prose :global(h1) {
font-size: 1.625rem;
}
.prose :global(h2) {
font-size: 1.3125rem;
}
.prose :global(h3) {
font-size: 1.125rem;
}
.prose :global(:first-child) {
margin-top: 0;
}
.prose :global(:is(p, ul, ol, pre, blockquote, table)) {
margin: 0 0 1.1em;
}
.prose :global(:is(ul, ol)) {
padding-left: 1.4em;
}
.prose :global(ul) {
list-style: square;
}
.prose :global(ol) {
list-style: decimal;
}
.prose :global(li) {
margin: 0.25em 0;
}
.prose :global(li::marker) {
color: var(--green);
}
.prose :global(code) {
padding: 0.1em 0.3em;
font-family: var(--mono);
font-size: 0.8125em;
background: var(--paper-2);
}
.prose :global(pre) {
padding: 0.875rem 1rem;
overflow-x: auto;
line-height: 1.55;
background: var(--paper-2);
}
.prose :global(pre code) {
padding: 0;
background: none;
}
.prose :global(blockquote) {
position: relative;
padding-left: 1.25em;
color: var(--ink-2);
}
.prose :global(blockquote::before) {
position: absolute;
inset: 0.2em auto 0.2em 0;
width: 1px;
content: "";
background: var(--ochre);
}
.prose :global(hr) {
margin: 2em 0;
border: 0;
border-top: 1px solid var(--rule);
}
.prose :global(table) {
display: block;
overflow-x: auto;
font-variant-numeric: tabular-nums;
border-collapse: collapse;
}
.prose :global(:is(th, td)) {
padding: 0.4em 1em 0.4em 0;
text-align: left;
border-bottom: 1px solid var(--rule);
}
.prose :global(img) {
max-width: 100%;
}
.source,
.diff {
overflow-x: auto;
line-height: 1.6;
tab-size: 2;
white-space: pre;
}
.diff li {
position: relative;
min-width: max-content;
padding: 0 0.75rem 0 1.75rem;
}
.diff .same {
color: var(--ink-2);
}
.diff .added {
background: var(--green-wash);
}
.diff .removed {
background: var(--red-wash);
}
.diff .added::before,
.diff .removed::before {
position: absolute;
left: 0.5rem;
font-weight: 700;
}
.diff .added::before {
color: var(--green);
content: "+";
}
.diff .removed::before {
color: var(--red);
content: "−";
}
.diff ins {
text-decoration: none;
}
.diff del {
text-decoration-color: var(--red);
}
.diff .gap {
padding-block: 0.25rem;
margin: 0.5rem 0;
font-family: var(--sans);
font-size: 0.8125rem;
color: var(--ink-2);
border-block: 1px dashed var(--rule);
}
</style>
+288
View File
@@ -0,0 +1,288 @@
<script lang="ts">
import { ago, lithology, type Revision, shortSha } from "$lib/lodge";
interface Props {
at: number;
fresh: number;
machines: string[];
onselect: (seq: number, scrubbing: boolean) => void;
revisions: Revision[];
touched: Set<number> | null;
}
const { at, fresh, machines, onselect, revisions, touched }: Props = $props();
const BASE_HEIGHT = 20;
const CHANGE_HEIGHT = 6;
const MAX_CHANGES = 6;
const NUMERAL_EVERY = 5;
let column = $state<HTMLElement | undefined>();
let scrubbing = $state(false);
const height = (rev: Revision) =>
BASE_HEIGHT + Math.min(rev.changes.length, MAX_CHANGES) * CHANGE_HEIGHT;
const numbered = (rev: Revision, index: number) =>
rev.seq === at ||
rev.seq % NUMERAL_EVERY === 0 ||
index === 0 ||
index === revisions.length - 1;
const formation = (rev: Revision, index: number) =>
index === 0 || revisions[index - 1].main_sha !== rev.main_sha;
const describeBed = (rev: Revision) =>
`Revision ${rev.seq}, ${rev.machine}, ${rev.changes.length} changed, ${rev.main_branch || "detached"} ${shortSha(rev.main_sha)}, ${ago(rev.time)}`;
const seqAt = (x: number, y: number): number | null => {
const bed = document.elementFromPoint(x, y)?.closest("[data-seq]");
return bed instanceof HTMLElement ? Number(bed.dataset.seq) : null;
};
const press = (event: PointerEvent) => {
const seq = seqAt(event.clientX, event.clientY);
if (seq === null || event.button !== 0) {
return;
}
if (event.pointerType === "mouse" || seq === at) {
scrubbing = true;
column?.setPointerCapture(event.pointerId);
onselect(seq, true);
}
};
const drag = (event: PointerEvent) => {
if (!(scrubbing && column)) {
return;
}
const bounds = column.getBoundingClientRect();
const seq = seqAt(bounds.left + bounds.width / 2, event.clientY);
if (seq !== null && seq !== at) {
onselect(seq, true);
}
};
const release = () => {
if (scrubbing) {
scrubbing = false;
onselect(at, false);
}
};
const step = (event: KeyboardEvent) => {
const move = { ArrowDown: 1, ArrowUp: -1, j: 1, k: -1 }[event.key];
if (!move) {
return;
}
event.preventDefault();
const index = revisions.findIndex((rev) => rev.seq === at) + move;
const next = revisions[Math.max(0, Math.min(revisions.length - 1, index))];
onselect(next.seq, false);
column?.querySelector<HTMLElement>(`[data-seq="${next.seq}"]`)?.focus();
};
$effect(() => {
column
?.querySelector(`[data-seq="${at}"]`)
?.scrollIntoView({ block: "nearest" });
});
</script>
<div
aria-label="Revision history, newest at the top"
aria-orientation="vertical"
class="column"
onkeydown={step}
onpointercancel={release}
onpointerdown={press}
onpointermove={drag}
onpointerup={release}
role="listbox"
tabindex="-1"
bind:this={column}
class:scrubbing
>
{#each revisions as rev, index (rev.seq)}
<button
aria-label={describeBed(rev)}
aria-selected={rev.seq === at}
class="bed"
data-seq={rev.seq}
onclick={() => onselect(rev.seq, false)}
role="option"
title="{rev.machine} · {ago(rev.time)}"
type="button"
style:height="{height(rev)}px"
class:above={rev.seq > at}
class:fresh={rev.seq === fresh}
class:quiet={touched !== null && !touched.has(rev.seq)}
>
<span class="depth stamp">{numbered(rev, index) ? rev.seq : ""}</span>
<span class="fill {lithology(rev.machine, machines)}"></span>
<span class="mark"></span>
<span class="formation">
{#if formation(rev, index)}
<span class="branch">{rev.main_branch || "detached"}</span>
<span class="mono">{shortSha(rev.main_sha)}</span>
{/if}
</span>
</button>
{/each}
</div>
<style>
.column {
display: flex;
flex-direction: column;
padding-bottom: 3rem;
overflow-y: auto;
overscroll-behavior: contain;
user-select: none;
scrollbar-width: none;
background-color: var(--paper-2);
background-image:
linear-gradient(var(--grid-strong) 1px, transparent 1px),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size:
100% 40px,
100% 8px,
8px 100%;
}
.column.scrubbing {
cursor: grabbing;
}
.bed {
position: relative;
display: grid;
flex: none;
grid-template-columns: 2.75rem 4.5rem 0.5rem 1fr;
align-items: stretch;
padding: 0;
color: var(--ink);
text-align: left;
touch-action: pan-y;
background: none;
border: 0;
border-top: 1px solid var(--rule);
}
.bed.fresh {
animation: settle 600ms var(--ease);
}
.depth {
padding-right: 0.5rem;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ink-2);
text-align: right;
}
.fill {
border-inline: 1px solid var(--ink);
transition: opacity 150ms var(--ease);
}
.bed.quiet .fill {
--l: var(--ink-2);
}
.bed.quiet .fill::after {
opacity: 0.6;
}
.bed:not(.quiet) .mark {
background: var(--green);
}
.column:not(:has(.quiet)) .mark {
background: none;
}
.bed.above .fill::after {
display: none;
}
.formation {
display: flex;
gap: 0.375rem;
min-width: 0;
padding-left: 0.375rem;
font-size: 0.75rem;
line-height: 1.25rem;
color: var(--ink-2);
white-space: nowrap;
}
.branch {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-stretch: 80%;
color: var(--ink);
}
.bed:hover .fill {
--l: var(--green);
}
.bed[aria-selected="true"] {
touch-action: none;
}
.bed[aria-selected="true"]::before {
position: absolute;
inset: -1px 0 auto;
z-index: 1;
height: 3px;
content: "";
background: var(--red);
}
.bed[aria-selected="true"]::after {
position: absolute;
top: -1.375rem;
right: 0;
z-index: 3;
width: 0.625rem;
height: 2.75rem;
content: "";
background: var(--red);
}
.bed:first-child[aria-selected="true"]::after {
top: 0;
}
.bed:first-child[aria-selected="true"]::before {
top: 0;
}
.bed[aria-selected="true"] .depth {
color: var(--red);
}
.bed:focus-visible {
z-index: 2;
outline-offset: -2px;
}
@media (max-width: 56rem) {
.bed {
grid-template-columns: 1.75rem 1fr 0.25rem;
}
.formation {
display: none;
}
.depth {
padding-right: 0.25rem;
font-size: 0.6875rem;
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
// biome-ignore-all lint/suspicious/noEmptySource: temporary
+136
View File
@@ -0,0 +1,136 @@
import { api } from "$lib/api/client";
export interface Change {
base: string;
hash: string;
path: string;
space: string;
}
export interface Revision {
changes: Change[];
machine: string;
main_branch: string;
main_sha: string;
seq: number;
time: number;
}
export interface Repo {
key: string;
name: string;
seq: number;
time: number;
}
export interface Machine {
created_at: number;
id: number;
last_push: number;
name: string;
revisions: number;
}
export interface FileEntry {
hash: string;
time: number;
}
export interface RepoState {
seq: number;
spaces: Record<string, Record<string, FileEntry>>;
}
export interface Code {
code: string;
command: string;
expires_at: number;
}
export const SHARED = "shared";
const PATTERNS = 7;
const WEB = "web";
const REVOKED = "revoked";
const SHORT_SHA = 7;
const BRANCH_PREFIX = "branch:";
export const lithology = (machine: string, machines: string[]): string => {
if (machine === WEB) {
return "lith lith-web";
}
const index = machines.indexOf(machine);
if (machine === REVOKED || index < 0) {
return "lith lith-gone";
}
return `lith lith-${index % PATTERNS}`;
};
export const shortSha = (sha: string): string =>
sha ? sha.slice(0, SHORT_SHA) : "no commit";
export const spaceName = (space: string): string =>
space.startsWith(BRANCH_PREFIX) ? space.slice(BRANCH_PREFIX.length) : space;
export const fileId = (space: string, path: string): string =>
`${space}\n${path}`;
export const changeKind = (
change: Change
): "added" | "deleted" | "modified" => {
if (!change.base) {
return "added";
}
return change.hash ? "modified" : "deleted";
};
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31_536_000_000],
["month", 2_592_000_000],
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
];
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const absolute = new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
});
export const ago = (time: number): string => {
if (!time) {
return "never";
}
const delta = time - Date.now();
for (const [unit, size] of UNITS) {
if (Math.abs(delta) >= size) {
return relative.format(Math.round(delta / size), unit);
}
}
return "just now";
};
export const exact = (time: number): string => absolute.format(time);
const blobs = new Map<string, Promise<Uint8Array>>();
export const blob = (hash: string): Promise<Uint8Array> => {
let pending = blobs.get(hash);
if (!pending) {
pending = api.bytes(`/blobs/${hash}`);
blobs.set(hash, pending);
}
return pending;
};
const decoder = new TextDecoder("utf-8", { fatal: true });
export const text = (bytes: Uint8Array): string | null => {
if (bytes.includes(0)) {
return null;
}
try {
return decoder.decode(bytes);
} catch {
return null;
}
};
+32
View File
@@ -0,0 +1,32 @@
import { goto } from "$app/navigation";
import { ApiError, api } from "$lib/api/client";
export const session = $state({ signedIn: false });
export const requireSession = async (): Promise<void> => {
try {
await api.get("/session");
session.signedIn = true;
} catch {
session.signedIn = false;
await goto("/login");
}
};
export const signOut = async (): Promise<void> => {
await api.del("/session");
session.signedIn = false;
await goto("/login");
};
export const describe = (error: unknown): string => {
if (error instanceof ApiError && error.status === 401) {
session.signedIn = false;
goto("/login");
return "Your session ended. Sign in again.";
}
if (error instanceof ApiError) {
return error.message;
}
return "The server did not answer. Check that lodge-server is running and try again.";
};
+15
View File
@@ -0,0 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: any }
? Omit<T, "children">
: T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
+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>