11import { useState , useCallback , useRef , useEffect , useMemo } from 'react' ;
2+ import type { CSSProperties } from 'react' ;
23import type * as Monaco from 'monaco-editor' ;
34import type { FileEntry } from '@quarto/preview-renderer/types/project' ;
45import type { Diagnostic , PreviewNodeEditPayload } from '@quarto/preview-renderer/types/diagnostic' ;
@@ -281,6 +282,113 @@ async function doRender(
281282 }
282283}
283284
285+ // Exported for unit test. SPURIOUS = the edit round-tripped to identical QMD.
286+ export function classifyCommitOutcome (
287+ newQmd : string ,
288+ renderedContent : string ,
289+ ) : 'change' | 'spurious' {
290+ return newQmd === renderedContent ? 'spurious' : 'change' ;
291+ }
292+
293+ /**
294+ * G22: commit-status indicator — a translucent "glass" status dot in the
295+ * preview's bottom-right corner that glows out of the page and fades to nothing.
296+ *
297+ * pending → amber (commit going out)
298+ * change → green (real diff written)
299+ * spurious → blue (no-op round-trip — the false-dirty bug class)
300+ * error → (not lit here; the error pill takes over the corner)
301+ *
302+ * Modern / non-skeuomorphic: no bezel, no fixture, no persistence. The dot is
303+ * defined by LIGHT not by a housing — a colored radial bloom, a thin colored
304+ * glass rim, and a `backdrop-filter` refraction — so it reads on a white page
305+ * now and on a dark background later. Idle → opacity 0 (gone). Lit states bloom
306+ * in (scale + fade) and dim back out; only the attention state (blue) pulses.
307+ */
308+ const COMMIT_BULB_CSS = `
309+ @keyframes q2-bulb-pulse {
310+ 0%, 100% { opacity: 0.84; }
311+ 50% { opacity: 1; }
312+ }
313+ .q2-commit-bulb {
314+ position: absolute;
315+ /* Sits in the bottom-right corner where the error pill appears, so on error
316+ the pill visually replaces the bulb. */
317+ bottom: 20px;
318+ right: 20px;
319+ width: 13px;
320+ height: 13px;
321+ border-radius: 50%;
322+ background: radial-gradient(circle at 50% 45%, var(--c-bright) 0%, var(--c-soft) 58%, transparent 100%);
323+ border: 1px solid var(--c-rim);
324+ backdrop-filter: blur(3px) saturate(1.4);
325+ -webkit-backdrop-filter: blur(3px) saturate(1.4);
326+ box-shadow: 0 0 var(--c-glow-blur) var(--c-glow-spread) var(--c-glow);
327+ opacity: var(--bulb-opacity);
328+ transform: scale(var(--bulb-scale));
329+ transition:
330+ opacity 300ms ease,
331+ box-shadow 220ms ease,
332+ background 220ms ease,
333+ border-color 220ms ease,
334+ transform 300ms cubic-bezier(0.2, 0.7, 0.3, 1.3);
335+ pointer-events: none;
336+ z-index: 50;
337+ will-change: opacity, transform;
338+ }
339+ /* Gentle, slow breath — only the attention state (blue) pulses. */
340+ .q2-commit-bulb[data-pulse="1"] { animation: q2-bulb-pulse 1.5s ease-in-out infinite; }
341+ ` ;
342+
343+ export function CommitStatusBulb ( {
344+ status,
345+ } : {
346+ status : 'idle' | 'pending' | 'change' | 'spurious' | 'error' ;
347+ } ) {
348+ // RGB triples + per-state "loudness" — green (a normal change) is quiet; blue
349+ // (spurious) glows harder, pulses, and lingers so it pulls the eye. Derived
350+ // from one hue so it carries to light and dark backgrounds.
351+ //
352+ // 'error' is intentionally NOT lit here: on error the bulb hands off to the
353+ // error pill (PreviewErrorOverlay), which appears in the same corner — see the
354+ // render below where the bulb is suppressed while a commit error is showing.
355+ const PALETTE : Record <
356+ 'pending' | 'change' | 'spurious' ,
357+ { rgb : string ; glowA : number ; blur : string ; spread : string ; pulse : boolean ; title : string }
358+ > = {
359+ pending : { rgb : '255,160,30' , glowA : 0.42 , blur : '9px' , spread : '0px' , pulse : false , title : 'Committing…' } ,
360+ change : { rgb : '40,200,100' , glowA : 0.36 , blur : '8px' , spread : '0px' , pulse : false , title : 'Change committed' } ,
361+ spurious : { rgb : '60,140,255' , glowA : 0.6 , blur : '13px' , spread : '1px' , pulse : true , title : 'No change (spurious edit)' } ,
362+ } ;
363+ const on = status === 'pending' || status === 'change' || status === 'spurious' ;
364+ const p = on ? PALETTE [ status ] : null ;
365+ const rgb = p ?. rgb ?? '128,128,128' ;
366+ return (
367+ < >
368+ < style > { COMMIT_BULB_CSS } </ style >
369+ < div
370+ className = "q2-commit-bulb"
371+ data-pulse = { p ?. pulse ? '1' : undefined }
372+ title = { p ?. title }
373+ style = {
374+ {
375+ '--c-bright' : `rgba(${ rgb } ,0.92)` ,
376+ '--c-soft' : `rgba(${ rgb } ,0.30)` ,
377+ '--c-rim' : `rgba(${ rgb } ,0.55)` ,
378+ '--c-glow' : `rgba(${ rgb } ,${ p ?. glowA ?? 0 } )` ,
379+ '--c-glow-blur' : p ?. blur ?? '0px' ,
380+ '--c-glow-spread' : p ?. spread ?? '0px' ,
381+ // No persistence: idle fades to nothing and shrinks slightly so lit
382+ // states bloom back in.
383+ '--bulb-opacity' : on ? 1 : 0 ,
384+ '--bulb-scale' : on ? 1 : 0.55 ,
385+ } as CSSProperties
386+ }
387+ />
388+ </ >
389+ ) ;
390+ }
391+
284392export default function ReactPreview ( {
285393 content,
286394 currentFile,
@@ -391,6 +499,51 @@ export default function ReactPreview({
391499 const renderTimeoutRef = useRef < number | null > ( null ) ;
392500 const lastContentRef = useRef < string > ( '' ) ;
393501
502+ // G22: commit-status indicator. Every commit channel (text / subtree /
503+ // nesting) funnels through handleSetAst, so we classify the outcome there and
504+ // drive ONE bulb overlay from a single status state — no per-site plumbing.
505+ const [ commitStatus , setCommitStatus ] = useState <
506+ 'idle' | 'pending' | 'change' | 'spurious' | 'error'
507+ > ( 'idle' ) ;
508+ const commitStatusTimerRef = useRef < number | null > ( null ) ;
509+ const commitPendingSinceRef = useRef < number > ( 0 ) ;
510+ // G22: the message for a rejected commit, surfaced in the existing
511+ // PreviewErrorOverlay. Persists until the next SUCCESSFUL commit.
512+ const [ commitError , setCommitError ] = useState < string | null > ( null ) ;
513+
514+ // Light the bulb amber when a commit goes out.
515+ const beginCommitStatus = useCallback ( ( ) => {
516+ if ( commitStatusTimerRef . current !== null ) clearTimeout ( commitStatusTimerRef . current ) ;
517+ commitPendingSinceRef . current = Date . now ( ) ;
518+ setCommitStatus ( 'pending' ) ;
519+ } , [ ] ) ;
520+
521+ // applyNodeEdit is synchronous, so hold 'pending' a minimum so it is visible,
522+ // then flip to the result colour, then auto-clear to idle.
523+ const settleCommitStatus = useCallback (
524+ ( result : 'change' | 'spurious' | 'error' ) => {
525+ const MIN_PENDING_MS = 200 ;
526+ // Loudness via dwell time: a normal change blinks briefly; a spurious /
527+ // rejected commit lingers longer so it draws the eye.
528+ const HOLD_MS = { change : 450 , spurious : 1100 , error : 1700 } [ result ] ;
529+ const elapsed = Date . now ( ) - commitPendingSinceRef . current ;
530+ const delay = Math . max ( 0 , MIN_PENDING_MS - elapsed ) ;
531+ if ( commitStatusTimerRef . current !== null ) clearTimeout ( commitStatusTimerRef . current ) ;
532+ commitStatusTimerRef . current = window . setTimeout ( ( ) => {
533+ setCommitStatus ( result ) ;
534+ commitStatusTimerRef . current = window . setTimeout ( ( ) => {
535+ setCommitStatus ( 'idle' ) ;
536+ commitStatusTimerRef . current = null ;
537+ } , HOLD_MS ) ;
538+ } , delay ) ;
539+ } ,
540+ [ ] ,
541+ ) ;
542+
543+ useEffect ( ( ) => ( ) => {
544+ if ( commitStatusTimerRef . current !== null ) clearTimeout ( commitStatusTimerRef . current ) ;
545+ } , [ ] ) ;
546+
394547 // Handler for cross-document navigation
395548 const handleNavigateToDocument = useCallback (
396549 ( targetPath : string , anchor : string | null ) => {
@@ -522,13 +675,17 @@ export default function ReactPreview({
522675 ) ;
523676 return ;
524677 }
678+ // G22: a commit is going out — light the bulb amber.
679+ beginCommitStatus ( ) ;
525680 try {
526681 let modifiedSubtreeJson : string ;
527682 if ( edit . channel === 'text' ) {
528683 // Text channel: parse raw QMD → Pandoc JSON → apply_node_edit.
529684 const parseResult = parseQmdContentSync ( edit . newText ) ;
530685 if ( ! parseResult . success || ! parseResult . ast ) {
531686 console . error ( 'parse_qmd_content failed:' , parseResult . error ) ;
687+ settleCommitStatus ( 'error' ) ; // G22: parse rejected
688+ setCommitError ( `Edit could not be parsed: ${ parseResult . error ?? 'unknown error' } ` ) ;
532689 return ;
533690 }
534691 modifiedSubtreeJson = parseResult . ast ;
@@ -544,9 +701,15 @@ export default function ReactPreview({
544701 edit . destinationSourceInfoJson ,
545702 modifiedSubtreeJson ,
546703 ) ;
704+ // G22: a commit that round-trips to identical QMD is a SPURIOUS edit
705+ // (the false-dirty bug class) — surface it instead of silently writing.
706+ settleCommitStatus ( classifyCommitOutcome ( newQmd , rendered . renderedContent ) ) ;
707+ setCommitError ( null ) ; // G22: a successful commit clears the prior error
547708 onContentRewrite ( newQmd ) ;
548709 } catch ( err ) {
549710 console . error ( 'apply_node_edit failed:' , err ) ;
711+ settleCommitStatus ( 'error' ) ; // G22: apply rejected
712+ setCommitError ( `Edit could not be applied: ${ err instanceof Error ? err . message : String ( err ) } ` ) ;
550713 }
551714 return ;
552715 }
@@ -557,11 +720,14 @@ export default function ReactPreview({
557720 console . error ( 'Failed to write AST back to QMD:' , err ) ;
558721 }
559722 } ,
560- [ content , onContentRewrite , format , rendered . untransformedAstJson , rendered . renderedContent ] ,
723+ [ content , onContentRewrite , format , rendered . untransformedAstJson , rendered . renderedContent , beginCommitStatus , settleCommitStatus ] ,
561724 ) ;
562725
563726 return (
564727 < div style = { { height : '100%' , display : 'flex' , flexDirection : 'column' , position : 'relative' } } >
728+ { /* Bulb lives in the same bottom-right corner as the error pill; while a
729+ commit error is showing, the pill replaces the bulb (bulb → idle). */ }
730+ < CommitStatusBulb status = { commitError ? 'idle' : commitStatus } />
565731 < div style = { { flex : 1 , position : 'relative' , overflow : 'hidden' } } >
566732 { rendered . astJson && ( previewState === 'GOOD' || previewState === 'ERROR_FROM_GOOD' ) ? (
567733 < ReactRenderer
@@ -596,8 +762,8 @@ export default function ReactPreview({
596762 </ div >
597763 { /* Error overlay shown when error occurs after successful render */ }
598764 < PreviewErrorOverlay
599- error = { currentError }
600- visible = { previewState === 'ERROR_FROM_GOOD' }
765+ error = { currentError ?? ( commitError ? { message : commitError } : null ) }
766+ visible = { previewState === 'ERROR_FROM_GOOD' || commitError != null }
601767 collapsed = { errorOverlayCollapsed }
602768 onToggleCollapsed = { setErrorOverlayCollapsed }
603769 />
0 commit comments