1- import { useCallback , useEffect , useState } from "react" ;
1+ import { memo , useCallback , useEffect , useRef , useState } from "react" ;
22import { Link , useParams } from "react-router-dom" ;
33import {
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. */
244254function 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+
365460function BackLink ( ) {
366461 return (
367462 < Link
0 commit comments