@@ -32,6 +32,25 @@ import {
3232} from '#app/utils/misc.tsx'
3333import { type Route } from './+types/cache.ts'
3434
35+ function normalizeSearchQuery ( query : string ) {
36+ return query . trim ( ) . toLowerCase ( )
37+ }
38+
39+ function entryValueMatches ( value : unknown , query : string ) {
40+ if ( ! query ) return true
41+ if ( value === null || value === undefined ) return false
42+ if ( typeof value === 'string' ) {
43+ return value . toLowerCase ( ) . includes ( query )
44+ }
45+ try {
46+ const serialized = JSON . stringify ( value )
47+ if ( ! serialized ) return false
48+ return serialized . toLowerCase ( ) . includes ( query )
49+ } catch {
50+ return false
51+ }
52+ }
53+
3554export async function loader ( { request } : Route . LoaderArgs ) {
3655 ensureUndeployed ( )
3756 const currentWorkshopId = getEnv ( ) . EPICSHOP_WORKSHOP_INSTANCE_ID
@@ -56,6 +75,7 @@ export async function loader({ request }: Route.LoaderArgs) {
5675 currentWorkshopId ,
5776 ...( globalDirExists ? [ 'global' ] : [ ] ) ,
5877 ]
78+ const normalizedQuery = normalizeSearchQuery ( filterQuery )
5979
6080 // Filter caches based on search query and selected workshops
6181 const filteredCaches = allCaches
@@ -67,24 +87,36 @@ export async function loader({ request }: Route.LoaderArgs) {
6787 . map ( ( workshopCache ) => ( {
6888 ...workshopCache ,
6989 caches : workshopCache . caches
70- . map ( ( cache ) => ( {
71- ...cache ,
72- entries : cache . entries . filter (
73- ( entry ) =>
74- filterQuery === '' ||
75- entry . key . toLowerCase ( ) . includes ( filterQuery . toLowerCase ( ) ) ||
76- cache . name . toLowerCase ( ) . includes ( filterQuery . toLowerCase ( ) ) ,
77- ) ,
78- } ) )
79- . filter ( ( cache ) => cache . entries . length > 0 || filterQuery === '' ) ,
90+ . map ( ( cache ) => {
91+ const cacheNameMatches =
92+ normalizedQuery === ''
93+ ? true
94+ : cache . name . toLowerCase ( ) . includes ( normalizedQuery )
95+ const entries = cache . entries
96+ . filter ( ( entry ) => {
97+ if ( normalizedQuery === '' ) return true
98+ if ( cacheNameMatches ) return true
99+ if ( entry . key . toLowerCase ( ) . includes ( normalizedQuery ) ) return true
100+ return entryValueMatches ( entry . entry . value , normalizedQuery )
101+ } )
102+ . map ( ( entry ) => ( {
103+ key : entry . key ,
104+ filename : entry . filename ,
105+ size : entry . size ,
106+ filepath : entry . filepath ,
107+ metadata : entry . entry . metadata ,
108+ } ) )
109+ return { ...cache , entries }
110+ } )
111+ . filter ( ( cache ) => cache . entries . length > 0 || normalizedQuery === '' ) ,
80112 } ) )
81113 . filter (
82- ( workshopCache ) => workshopCache . caches . length > 0 || filterQuery === '' ,
114+ ( workshopCache ) =>
115+ workshopCache . caches . length > 0 || normalizedQuery === '' ,
83116 )
84117
85118 return {
86119 currentWorkshopId,
87- allWorkshopCaches : allCaches ,
88120 filteredCaches,
89121 filterQuery,
90122 selectedWorkshops,
@@ -270,23 +302,75 @@ function InlineEntryEditor({
270302 workshopId,
271303 cacheName,
272304 filename,
273- currentValue,
274305 entryKey,
275306} : {
276307 workshopId : string
277308 cacheName : string
278309 filename : string
279- currentValue : any
280310 entryKey : string
281311} ) {
282- const fetcher = useFetcher < typeof action > ( )
283- const [ editValue , setEditValue ] = useState (
284- JSON . stringify ( currentValue , null , 2 ) ,
285- )
312+ const entryFetcher = useFetcher < {
313+ entry : {
314+ value : unknown
315+ metadata : {
316+ createdTime : number
317+ ttl ?: number | null
318+ swr ?: number
319+ }
320+ } | null
321+ } > ( )
322+ const updateFetcher = useFetcher < typeof action > ( )
323+ const [ editValue , setEditValue ] = useState < string | null > ( null )
324+ const [ baselineValue , setBaselineValue ] = useState < string | null > ( null )
286325 const [ hasChanges , setHasChanges ] = useState ( false )
326+ const [ hasRequested , setHasRequested ] = useState ( false )
327+ const [ hasStartedLoading , setHasStartedLoading ] = useState ( false )
328+ const submittedValueRef = useRef < string | null > ( null )
329+ const prevUpdateStateRef = useRef < string > ( updateFetcher . state )
330+
331+ const entryPath = `${ workshopId } /${ cacheName } /${ filename } `
332+ const entryValue = entryFetcher . data ?. entry ?. value
333+ const isEntryLoading = entryFetcher . state === 'loading'
334+ const hasEntry = Boolean ( entryFetcher . data ?. entry )
335+ const entryMissing = entryFetcher . data ?. entry === null
336+ const entryFetchFailed =
337+ hasRequested &&
338+ hasStartedLoading &&
339+ ! isEntryLoading &&
340+ entryFetcher . data === undefined &&
341+ entryFetcher . state === 'idle'
342+
343+ useEffect ( ( ) => {
344+ if ( entryFetcher . state !== 'idle' ) {
345+ setHasStartedLoading ( true )
346+ }
347+ } , [ entryFetcher . state ] )
348+
349+ useEffect ( ( ) => {
350+ if ( hasEntry ) {
351+ const formattedValue = JSON . stringify ( entryValue , null , 2 ) ?? ''
352+ setEditValue ( formattedValue )
353+ setBaselineValue ( formattedValue )
354+ setHasChanges ( false )
355+ }
356+ } , [ hasEntry , entryValue ] )
357+
358+ useEffect ( ( ) => {
359+ const currentUpdateState = updateFetcher . state
360+ const wasSubmitting = prevUpdateStateRef . current !== 'idle'
361+ const nowIdle = currentUpdateState === 'idle'
362+
363+ if ( wasSubmitting && nowIdle && updateFetcher . data ?. status === 'success' ) {
364+ setBaselineValue ( submittedValueRef . current ?? '' )
365+ setHasChanges ( false )
366+ }
367+ prevUpdateStateRef . current = currentUpdateState
368+ } , [ updateFetcher . state , updateFetcher . data ?. status ] )
287369
288370 const handleSave = ( ) => {
289- void fetcher . submit (
371+ if ( editValue === null ) return
372+ submittedValueRef . current = editValue
373+ void updateFetcher . submit (
290374 {
291375 intent : 'update-entry' ,
292376 workshopId,
@@ -296,52 +380,106 @@ function InlineEntryEditor({
296380 } ,
297381 { method : 'POST' } ,
298382 )
299- setHasChanges ( false )
300383 }
301384
302385 const handleChange = ( value : string ) => {
303386 setEditValue ( value )
304- setHasChanges ( value !== JSON . stringify ( currentValue , null , 2 ) )
387+ if ( baselineValue === null ) {
388+ setHasChanges ( false )
389+ return
390+ }
391+ setHasChanges ( value !== baselineValue )
305392 }
306393
307394 const handleReset = ( ) => {
308- setEditValue ( JSON . stringify ( currentValue , null , 2 ) )
395+ if ( baselineValue === null ) return
396+ setEditValue ( baselineValue )
309397 setHasChanges ( false )
310398 }
311399
400+ const handleToggle = ( event : React . SyntheticEvent < HTMLDetailsElement > ) => {
401+ if ( event . currentTarget . open && ! hasRequested ) {
402+ void entryFetcher . load (
403+ href ( '/admin/cache/*' , {
404+ '*' : entryPath ,
405+ } ) ,
406+ )
407+ setHasRequested ( true )
408+ } else if ( ! event . currentTarget . open ) {
409+ // Reset on close to allow retry
410+ setHasRequested ( false )
411+ setHasStartedLoading ( false )
412+ }
413+ }
414+
415+ const handleRetry = ( ) => {
416+ void entryFetcher . load (
417+ href ( '/admin/cache/*' , {
418+ '*' : entryPath ,
419+ } ) ,
420+ )
421+ }
422+
312423 return (
313- < details className = "mt-2" >
424+ < details className = "mt-2" onToggle = { handleToggle } >
314425 < summary className = "text-muted-foreground hover:text-foreground cursor-pointer text-sm" >
315426 Edit entry details
316427 </ summary >
317428 < div className = "border-border bg-muted mt-2 space-y-3 rounded border p-3" >
318- < div >
319- < label className = "mb-1 block text-sm font-medium" > Key:</ label >
320- < code className = "bg-background rounded border px-2 py-1 text-sm" >
321- { entryKey }
322- </ code >
323- </ div >
324- < div >
325- < label className = "mb-1 block text-sm font-medium" > Value:</ label >
326- < textarea
327- value = { editValue }
328- onChange = { ( e ) => handleChange ( e . target . value ) }
329- className = "resize-vertical border-border bg-background text-foreground focus:ring-ring h-32 w-full rounded border p-2 font-mono text-sm focus:ring-2 focus:outline-none"
330- placeholder = "Enter JSON value..."
331- />
332- </ div >
333- < div className = "flex gap-2" >
334- < Button
335- varient = "primary"
336- onClick = { handleSave }
337- disabled = { ! hasChanges || fetcher . state !== 'idle' }
338- >
339- { fetcher . state !== 'idle' ? 'Saving...' : 'Save' }
340- </ Button >
341- < Button varient = "mono" onClick = { handleReset } disabled = { ! hasChanges } >
342- Reset
343- </ Button >
344- </ div >
429+ { isEntryLoading ? (
430+ < p className = "text-muted-foreground text-sm" > Loading entry...</ p >
431+ ) : entryFetchFailed ? (
432+ < div className = "space-y-2" >
433+ < p className = "text-destructive text-sm" >
434+ Failed to load entry details.
435+ </ p >
436+ < Button varient = "mono" onClick = { handleRetry } >
437+ Retry
438+ </ Button >
439+ </ div >
440+ ) : entryMissing ? (
441+ < p className = "text-destructive text-sm" >
442+ Entry details were not found.
443+ </ p >
444+ ) : hasEntry ? (
445+ < >
446+ < div >
447+ < label className = "mb-1 block text-sm font-medium" > Key:</ label >
448+ < code className = "bg-background rounded border px-2 py-1 text-sm" >
449+ { entryKey }
450+ </ code >
451+ </ div >
452+ < div >
453+ < label className = "mb-1 block text-sm font-medium" > Value:</ label >
454+ < textarea
455+ value = { editValue ?? '' }
456+ onChange = { ( e ) => handleChange ( e . target . value ) }
457+ className = "resize-vertical border-border bg-background text-foreground focus:ring-ring h-32 w-full rounded border p-2 font-mono text-sm focus:ring-2 focus:outline-none"
458+ placeholder = "Enter JSON value..."
459+ />
460+ </ div >
461+ < div className = "flex gap-2" >
462+ < Button
463+ varient = "primary"
464+ onClick = { handleSave }
465+ disabled = { ! hasChanges || updateFetcher . state !== 'idle' }
466+ >
467+ { updateFetcher . state !== 'idle' ? 'Saving...' : 'Save' }
468+ </ Button >
469+ < Button
470+ varient = "mono"
471+ onClick = { handleReset }
472+ disabled = { ! hasChanges }
473+ >
474+ Reset
475+ </ Button >
476+ </ div >
477+ </ >
478+ ) : (
479+ < p className = "text-muted-foreground text-sm" >
480+ Open to load entry details.
481+ </ p >
482+ ) }
345483 </ div >
346484 </ details >
347485 )
@@ -609,7 +747,7 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
609747
610748 < div className = "space-y-2" >
611749 { cache . entries . map (
612- ( { key, entry , filename, size, filepath } ) => (
750+ ( { key, metadata , filename, size, filepath } ) => (
613751 < div
614752 key = { key }
615753 className = "border-border bg-background rounded border p-3"
@@ -632,7 +770,7 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
632770 </ span >
633771 ) : null }
634772 </ div >
635- < CacheMetadata metadata = { entry . metadata } />
773+ < CacheMetadata metadata = { metadata } />
636774 </ div >
637775 < div className = "ml-4 flex shrink-0 gap-1" >
638776 < a
@@ -679,7 +817,6 @@ export default function CacheManagement({ loaderData }: Route.ComponentProps) {
679817 workshopId = { workshopCache . workshopId }
680818 cacheName = { cache . name }
681819 filename = { filename }
682- currentValue = { entry . value }
683820 entryKey = { key }
684821 />
685822 </ div >
0 commit comments