Skip to content

Commit cc2e16a

Browse files
committed
feat(frontend): Bifurcate version history actions into semantic zones
The version ledger row mixed a state-mutating control (Restore) with read-only actions (Copy link, View) in one flowing flex group, so the right-aligned cluster shifted horizontally on desktop and broke into unpredictable line wraps on mobile, eroding spatial memory and inviting misclicks on the mutating control. Split each row into two physically isolated, ARIA-labelled interaction zones: Zone 1 couples the version badge to the conditional Restore so the state-change action anchors to the state indicator; Zone 2 keeps Copy link, Copy content, and View in a dedicated flex container whose mutual order stays invariant across every breakpoint. Add a Copy content action that lazily fetches a version's bytes, sharing one per-hash promise cache with View so each version is fetched at most once and concurrent clicks collapse to a single request. Model the CopyButton source as a discriminated union (eager value xor lazy load) and derive version state through a total function, making invalid states unrepresentable. Extract a memoized VersionHistoryRow so a single toggle no longer re-renders the unbounded append-only ledger.
1 parent fad6d70 commit cc2e16a

2 files changed

Lines changed: 220 additions & 106 deletions

File tree

frontend/src/SnippetDetail.tsx

Lines changed: 190 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from "react";
1+
import { memo, useCallback, useEffect, useRef, useState } from "react";
22
import { Link, useParams } from "react-router-dom";
33
import {
44
api,
@@ -239,6 +239,16 @@ function ContentTypeEditor({
239239
);
240240
}
241241

242+
/** A version's lifecycle stance within the append-only ledger. The newest
243+
* entry is `current`; everything before it is `historic` and restorable. */
244+
type VersionState = "current" | "historic";
245+
246+
/** Total function from ledger position to lifecycle state — index 0 is the
247+
* live pointer, the single source of truth for the badge and restore affordance. */
248+
function determineVersionState(index: number): VersionState {
249+
return index === 0 ? "current" : "historic";
250+
}
251+
242252
/** The version ledger, newest first. Each entry can be previewed and restored;
243253
* restoring repoints the snippet and appends a new version. */
244254
function HistoryList({
@@ -250,39 +260,64 @@ function HistoryList({
250260
history: HistoryItem[];
251261
onRestored: () => void;
252262
}) {
253-
const [preview, setPreview] = useState<{ hash: string; content: string } | null>(
254-
null,
255-
);
263+
const [openHash, setOpenHash] = useState<string | null>(null);
256264
const [busyHash, setBusyHash] = useState<string | null>(null);
257265
const [error, setError] = useState<string | null>(null);
258266

259-
const view = async (hash: string) => {
260-
setError(null);
261-
if (preview?.hash === hash) {
262-
setPreview(null);
263-
return;
264-
}
265-
try {
266-
const version = await api.getVersion(id, hash);
267-
setPreview({ hash, content: version.content });
268-
} catch (err) {
269-
setError(messageOf(err));
270-
}
271-
};
267+
// Per-hash content cache keyed by `target_hash`. Caching the *promise* (not
268+
// the resolved string) collapses concurrent View + Copy clicks into a single
269+
// in-flight request, and living in a ref means a cache fill triggers no
270+
// re-render — only the action that awaits it observes the bytes.
271+
const contentCache = useRef(new Map<string, Promise<string>>());
272272

273-
const restore = async (hash: string) => {
274-
setError(null);
275-
setBusyHash(hash);
276-
try {
277-
await api.restoreVersion(id, hash);
278-
setPreview(null);
279-
onRestored();
280-
} catch (err) {
281-
setError(messageOf(err));
282-
} finally {
283-
setBusyHash(null);
284-
}
285-
};
273+
const loadVersion = useCallback(
274+
(hash: string): Promise<string> => {
275+
const cache = contentCache.current;
276+
let pending = cache.get(hash);
277+
if (!pending) {
278+
pending = api.getVersion(id, hash).then((version) => version.content);
279+
// Evict on failure so a transient error can be retried.
280+
pending.catch(() => cache.delete(hash));
281+
cache.set(hash, pending);
282+
}
283+
return pending;
284+
},
285+
[id],
286+
);
287+
288+
const toggleView = useCallback(
289+
async (hash: string) => {
290+
setError(null);
291+
if (openHash === hash) {
292+
setOpenHash(null);
293+
return;
294+
}
295+
try {
296+
await loadVersion(hash);
297+
setOpenHash(hash);
298+
} catch (err) {
299+
setError(messageOf(err));
300+
}
301+
},
302+
[openHash, loadVersion],
303+
);
304+
305+
const restore = useCallback(
306+
async (hash: string) => {
307+
setError(null);
308+
setBusyHash(hash);
309+
try {
310+
await api.restoreVersion(id, hash);
311+
setOpenHash(null);
312+
onRestored();
313+
} catch (err) {
314+
setError(messageOf(err));
315+
} finally {
316+
setBusyHash(null);
317+
}
318+
},
319+
[id, onRestored],
320+
);
286321

287322
return (
288323
<section className="space-y-3">
@@ -292,76 +327,136 @@ function HistoryList({
292327
</h2>
293328
{error && <Banner tone="error">{error}</Banner>}
294329
<ol className="space-y-2">
295-
{history.map((entry, index) => {
296-
const isCurrent = index === 0;
297-
const isOpen = preview?.hash === entry.target_hash;
298-
return (
299-
<li
300-
key={`${entry.changed_at}-${entry.target_hash}`}
301-
className="space-y-3 rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-wisteria/40 md:px-5 md:py-4 lg:px-6"
302-
>
303-
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:gap-6">
304-
<div className="min-w-0">
305-
<code className="block truncate font-mono text-xs text-ink-soft">
306-
{entry.target_hash}
307-
</code>
308-
<span className="text-xs text-ink-faint">
309-
by {entry.editor_id} · {formatDate(entry.changed_at)}
310-
</span>
311-
</div>
312-
<div className="flex shrink-0 flex-wrap items-center gap-2">
313-
<Badge tone={isCurrent ? "wisteria" : "neutral"}>
314-
{isCurrent ? "current" : `v${history.length - index}`}
315-
</Badge>
316-
<CopyButton
317-
value={deliveryUrl(entry.target_hash)}
318-
label="Copy link"
319-
size="sm"
320-
/>
321-
<Button
322-
variant="secondary"
323-
size="sm"
324-
onClick={() => void view(entry.target_hash)}
325-
>
326-
{isOpen ? (
327-
<Icons.EyeOff className="h-4 w-4" aria-hidden />
328-
) : (
329-
<Icons.Eye className="h-4 w-4" aria-hidden />
330-
)}
331-
{isOpen ? "Hide" : "View"}
332-
</Button>
333-
{!isCurrent && (
334-
<Button
335-
variant="secondary"
336-
size="sm"
337-
loading={busyHash === entry.target_hash}
338-
onClick={() => void restore(entry.target_hash)}
339-
>
340-
{busyHash === entry.target_hash ? (
341-
"Restoring…"
342-
) : (
343-
<>
344-
<Icons.RotateCcw className="h-4 w-4" aria-hidden />
345-
Restore
346-
</>
347-
)}
348-
</Button>
349-
)}
350-
</div>
351-
</div>
352-
{isOpen && (
353-
<pre className="overflow-x-auto rounded bg-canvas px-3 py-2 font-mono text-xs text-ink">
354-
{preview.content}
355-
</pre>
356-
)}
357-
</li>
358-
);
359-
})}
330+
{history.map((entry, index) => (
331+
<VersionHistoryRow
332+
key={`${entry.changed_at}-${entry.target_hash}`}
333+
entry={entry}
334+
state={determineVersionState(index)}
335+
label={index === 0 ? "current" : `v${history.length - index}`}
336+
isOpen={openHash === entry.target_hash}
337+
busy={busyHash === entry.target_hash}
338+
loadVersion={loadVersion}
339+
onToggleView={toggleView}
340+
onRestore={restore}
341+
/>
342+
))}
360343
</ol>
361344
</section>
362345
);
363346
}
364347

348+
/** One row of the version ledger. Pure and memoized: the ledger is append-only
349+
* and unbounded, so stable rows keep a single toggle from re-rendering the list.
350+
*
351+
* The action surface is split into two physically isolated, ARIA-labelled zones:
352+
* Zone 1 (state & mutation) couples the version badge to the conditional restore
353+
* control; Zone 2 (invariant read-only) keeps Copy link / Copy content / View in
354+
* a fixed mutual order across every breakpoint. */
355+
const VersionHistoryRow = memo(function VersionHistoryRow({
356+
entry,
357+
state,
358+
label,
359+
isOpen,
360+
busy,
361+
loadVersion,
362+
onToggleView,
363+
onRestore,
364+
}: {
365+
entry: HistoryItem;
366+
state: VersionState;
367+
label: string;
368+
isOpen: boolean;
369+
busy: boolean;
370+
loadVersion: (hash: string) => Promise<string>;
371+
onToggleView: (hash: string) => void;
372+
onRestore: (hash: string) => void;
373+
}) {
374+
const [content, setContent] = useState<string | null>(null);
375+
376+
useEffect(() => {
377+
if (!isOpen) {
378+
return;
379+
}
380+
let active = true;
381+
// The promise is already cached by the parent; this only reads it.
382+
void loadVersion(entry.target_hash).then((text) => {
383+
if (active) {
384+
setContent(text);
385+
}
386+
});
387+
return () => {
388+
active = false;
389+
};
390+
}, [isOpen, entry.target_hash, loadVersion]);
391+
392+
return (
393+
<li className="space-y-3 rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-wisteria/40 md:px-5 md:py-4 lg:px-6">
394+
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
395+
{/* Zone 1 — State & Mutation. The restore control sits beside the badge
396+
so the state-changing action is anchored to the state indicator. */}
397+
<div className="min-w-0 space-y-1.5" role="group" aria-label="version state">
398+
<div className="min-w-0">
399+
<code className="block truncate font-mono text-xs text-ink-soft">
400+
{entry.target_hash}
401+
</code>
402+
<span className="text-xs text-ink-faint">
403+
by {entry.editor_id} · {formatDate(entry.changed_at)}
404+
</span>
405+
</div>
406+
<div className="flex items-center gap-2">
407+
<Badge tone={state === "current" ? "wisteria" : "neutral"}>{label}</Badge>
408+
{state === "historic" && (
409+
<Button
410+
variant="secondary"
411+
size="sm"
412+
loading={busy}
413+
onClick={() => onRestore(entry.target_hash)}
414+
>
415+
{busy ? (
416+
"Restoring…"
417+
) : (
418+
<>
419+
<Icons.RotateCcw className="h-4 w-4" aria-hidden />
420+
Restore
421+
</>
422+
)}
423+
</Button>
424+
)}
425+
</div>
426+
</div>
427+
428+
{/* Zone 2 — Invariant Read-Only. A dedicated flex container fixes the
429+
mutual order of these actions across all viewport sizes. */}
430+
<div
431+
className="flex shrink-0 flex-wrap items-center gap-2"
432+
role="group"
433+
aria-label="snippet actions"
434+
>
435+
<CopyButton value={deliveryUrl(entry.target_hash)} label="Copy link" size="sm" />
436+
<CopyButton
437+
load={() => loadVersion(entry.target_hash)}
438+
label="Copy content"
439+
size="sm"
440+
/>
441+
<Button variant="secondary" size="sm" onClick={() => onToggleView(entry.target_hash)}>
442+
{isOpen ? (
443+
<Icons.EyeOff className="h-4 w-4" aria-hidden />
444+
) : (
445+
<Icons.Eye className="h-4 w-4" aria-hidden />
446+
)}
447+
{isOpen ? "Hide" : "View"}
448+
</Button>
449+
</div>
450+
</div>
451+
{isOpen && content !== null && (
452+
<pre className="overflow-x-auto rounded bg-canvas px-3 py-2 font-mono text-xs text-ink">
453+
{content}
454+
</pre>
455+
)}
456+
</li>
457+
);
458+
});
459+
365460
function BackLink() {
366461
return (
367462
<Link

frontend/src/ui/CopyButton.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,52 @@ import { useState } from "react";
22
import { Button } from "./Button";
33
import { Check, Copy } from "./icons";
44

5+
/** Where a {@link CopyButton} gets its bytes: either an eager string known at
6+
* render time, or a lazy loader resolved on click. The union makes the two
7+
* mutually exclusive — a button is provably one or the other, never both. */
8+
type CopySource = { value: string } | { load: () => Promise<string> };
9+
510
/** A button that copies text to the clipboard and confirms briefly with an
6-
* icon swap. */
7-
export function CopyButton({
8-
value,
9-
label = "Copy",
10-
size = "md",
11-
}: {
12-
value: string;
13-
label?: string;
14-
size?: "sm" | "md";
15-
}) {
11+
* icon swap. Eager sources copy instantly; lazy sources fetch on click,
12+
* showing a spinner while the loader is in flight. */
13+
export function CopyButton(
14+
props: CopySource & {
15+
label?: string;
16+
size?: "sm" | "md";
17+
},
18+
) {
19+
const { label = "Copy", size = "md" } = props;
1620
const [copied, setCopied] = useState(false);
21+
const [loading, setLoading] = useState(false);
1722

1823
const copy = async () => {
24+
// A load already in flight; ignore re-clicks until it settles.
25+
if (loading) {
26+
return;
27+
}
1928
try {
20-
await navigator.clipboard.writeText(value);
29+
let text: string;
30+
if ("value" in props) {
31+
text = props.value;
32+
} else {
33+
setLoading(true);
34+
text = await props.load();
35+
}
36+
await navigator.clipboard.writeText(text);
2137
setCopied(true);
2238
setTimeout(() => setCopied(false), 1500);
2339
} catch {
2440
setCopied(false);
41+
} finally {
42+
setLoading(false);
2543
}
2644
};
2745

2846
return (
2947
<Button
3048
variant="secondary"
3149
size={size}
50+
loading={loading}
3251
onClick={() => void copy()}
3352
type="button"
3453
>

0 commit comments

Comments
 (0)