66import { useRenderer , useTerminalDimensions } from "@opentui/react" ;
77import { Suspense , lazy , useCallback , useEffect , useMemo , useState , useRef } from "react" ;
88import type { Dispatch , SetStateAction } from "react" ;
9- import type { AppBootstrap , CliInput , CommitDetailsMode , LayoutMode } from "../core/types" ;
9+ import type { AppBootstrap , CliInput , LayoutMode , ViewPreferences } from "../core/types" ;
1010import { canReloadInput , computeWatchSignature } from "../core/watch" ;
1111import type {
1212 HunkSessionBrokerClient ,
@@ -50,32 +50,6 @@ function clamp(value: number, min: number, max: number) {
5050 return Math . min ( Math . max ( value , min ) , max ) ;
5151}
5252
53- /** Preserve the active app view settings when rebuilding the current input. */
54- function withCurrentViewOptions (
55- input : CliInput ,
56- view : {
57- layoutMode : LayoutMode ;
58- themeId : string ;
59- showAgentNotes : boolean ;
60- showHunkHeaders : boolean ;
61- showLineNumbers : boolean ;
62- wrapLines : boolean ;
63- } ,
64- ) : CliInput {
65- return {
66- ...input ,
67- options : {
68- ...input . options ,
69- mode : view . layoutMode ,
70- theme : view . themeId ,
71- agentNotes : view . showAgentNotes ,
72- hunkHeaders : view . showHunkHeaders ,
73- lineNumbers : view . showLineNumbers ,
74- wrapLines : view . wrapLines ,
75- } ,
76- } ;
77- }
78-
7953/** Orchestrate global app state, layout, navigation, and pane coordination. */
8054export function App ( {
8155 bootstrap,
@@ -84,8 +58,8 @@ export function App({
8458 onQuit = ( ) => process . exit ( 0 ) ,
8559 onReloadSession,
8660 onMoveCommit,
87- commitDetailsMode = "full" ,
88- onCycleCommitDetailsMode ,
61+ view ,
62+ updateView ,
8963 liveCommentsByFileId,
9064 setLiveCommentsByFileId,
9165} : {
@@ -100,12 +74,13 @@ export function App({
10074 /** Provided when the source is commit-by-commit; called by > / <. */
10175 onMoveCommit ?: ( delta : number ) => MoveCommitResult ;
10276 /**
103- * Commit-details view mode, lifted to AppHost so it persists across the remount
104- * that fires on commit-cursor moves. Defaults to "full" when commit-review isn't
105- * active. The matching cycle action is `onCycleCommitDetailsMode`.
77+ * View preferences lifted to AppHost so user-toggled options (layout, theme,
78+ * sidebar, line numbers, wrap, hunk metadata, agent notes, commit-details mode)
79+ * persist across the App remount that fires on every commit-cursor move.
80+ * `updateView` accepts a partial patch that's merged into the bundle.
10681 */
107- commitDetailsMode ?: CommitDetailsMode ;
108- onCycleCommitDetailsMode ? : ( ) => void ;
82+ view : ViewPreferences ;
83+ updateView : ( patch : Partial < ViewPreferences > ) => void ;
10984 /**
11085 * Live-comment storage lifted to AppHost so notes survive the remount fired by
11186 * commit-cursor moves. AppHost buckets comments by sha and hands the active slice
@@ -127,16 +102,17 @@ export function App({
127102 const wrapToggleScrollTopRef = useRef < number | null > ( null ) ;
128103 const layoutToggleScrollTopRef = useRef < number | null > ( null ) ;
129104 const [ layoutToggleRequestId , setLayoutToggleRequestId ] = useState ( 0 ) ;
130- const [ layoutMode , setLayoutMode ] = useState < LayoutMode > ( bootstrap . initialMode ) ;
131- const [ themeId , setThemeId ] = useState (
132- ( ) => resolveTheme ( bootstrap . initialTheme , renderer . themeMode ) . id ,
133- ) ;
134- const [ showAgentNotes , setShowAgentNotes ] = useState ( bootstrap . initialShowAgentNotes ?? false ) ;
135- const [ showLineNumbers , setShowLineNumbers ] = useState ( bootstrap . initialShowLineNumbers ?? true ) ;
136- const [ wrapLines , setWrapLines ] = useState ( bootstrap . initialWrapLines ?? false ) ;
105+ const {
106+ layoutMode,
107+ themeId,
108+ showAgentNotes,
109+ showLineNumbers,
110+ wrapLines,
111+ showHunkHeaders,
112+ sidebarVisible,
113+ commitDetailsMode,
114+ } = view ;
137115 const [ codeHorizontalOffset , setCodeHorizontalOffset ] = useState ( 0 ) ;
138- const [ showHunkHeaders , setShowHunkHeaders ] = useState ( bootstrap . initialShowHunkHeaders ?? true ) ;
139- const [ sidebarVisible , setSidebarVisible ] = useState ( true ) ;
140116 const [ forceSidebarOpen , setForceSidebarOpen ] = useState ( false ) ;
141117 const [ showHelp , setShowHelp ] = useState ( false ) ;
142118 const [ focusArea , setFocusArea ] = useState < FocusArea > ( "files" ) ;
@@ -198,8 +174,8 @@ export function App({
198174 ) ;
199175
200176 const openAgentNotes = useCallback ( ( ) => {
201- setShowAgentNotes ( true ) ;
202- } , [ ] ) ;
177+ updateView ( { showAgentNotes : true } ) ;
178+ } , [ updateView ] ) ;
203179
204180 useHunkSessionBridge ( {
205181 addLiveComment : review . addLiveComment ,
@@ -339,20 +315,23 @@ export function App({
339315 ) ;
340316
341317 /** Preserve the current review position before changing the active diff layout. */
342- const selectLayoutMode = useCallback ( ( mode : LayoutMode ) => {
343- layoutToggleScrollTopRef . current = diffScrollRef . current ?. scrollTop ?? 0 ;
344- setLayoutToggleRequestId ( ( current ) => current + 1 ) ;
345- setLayoutMode ( mode ) ;
346- } , [ ] ) ;
318+ const selectLayoutMode = useCallback (
319+ ( mode : LayoutMode ) => {
320+ layoutToggleScrollTopRef . current = diffScrollRef . current ?. scrollTop ?? 0 ;
321+ setLayoutToggleRequestId ( ( current ) => current + 1 ) ;
322+ updateView ( { layoutMode : mode } ) ;
323+ } ,
324+ [ updateView ] ,
325+ ) ;
347326
348327 /** Toggle the global agent note layer on or off. */
349328 const toggleAgentNotes = ( ) => {
350- setShowAgentNotes ( ( current ) => ! current ) ;
329+ updateView ( { showAgentNotes : ! showAgentNotes } ) ;
351330 } ;
352331
353332 /** Toggle line-number gutters without changing the diff content itself. */
354333 const toggleLineNumbers = ( ) => {
355- setShowLineNumbers ( ( current ) => ! current ) ;
334+ updateView ( { showLineNumbers : ! showLineNumbers } ) ;
356335 } ;
357336
358337 /** Toggle whether diff code rows wrap instead of truncating to one terminal row. */
@@ -361,13 +340,13 @@ export function App({
361340 // top-most source row after wrapped row heights change.
362341 wrapToggleScrollTopRef . current = diffScrollRef . current ?. scrollTop ?? 0 ;
363342 setCodeHorizontalOffset ( 0 ) ;
364- setWrapLines ( ( current ) => ! current ) ;
343+ updateView ( { wrapLines : ! wrapLines } ) ;
365344 } ;
366345
367346 /** Toggle the sidebar, forcing it open on narrower layouts when the app can still fit both panes. */
368347 const toggleSidebar = ( ) => {
369348 if ( sidebarVisible && ( responsiveLayout . showSidebar || forceSidebarOpen ) ) {
370- setSidebarVisible ( false ) ;
349+ updateView ( { sidebarVisible : false } ) ;
371350 setForceSidebarOpen ( false ) ;
372351 return ;
373352 }
@@ -379,13 +358,13 @@ export function App({
379358 return ;
380359 }
381360
382- setSidebarVisible ( true ) ;
361+ updateView ( { sidebarVisible : true } ) ;
383362 setForceSidebarOpen ( ! responsiveLayout . showSidebar && canForceShowSidebar ) ;
384363 } ;
385364
386365 /** Toggle visibility of hunk metadata rows without changing the actual diff lines. */
387366 const toggleHunkHeaders = ( ) => {
388- setShowHunkHeaders ( ( current ) => ! current ) ;
367+ updateView ( { showHunkHeaders : ! showHunkHeaders } ) ;
389368 } ;
390369
391370 /** Jump to an annotated hunk without changing the global note visibility toggle. */
@@ -399,22 +378,17 @@ export function App({
399378 const canRefreshCurrentInput = canReloadInput ( bootstrap . input ) ;
400379 const watchEnabled = Boolean ( bootstrap . input . options . watch && canRefreshCurrentInput ) ;
401380
402- /** Rebuild the current diff source while preserving the active app view options. */
381+ /**
382+ * Rebuild the current diff source. View options live on AppHost above this
383+ * component's lifecycle so they survive the reload without needing to be
384+ * round-tripped through the bootstrap.
385+ */
403386 const refreshCurrentInput = useCallback ( async ( ) => {
404387 if ( ! canRefreshCurrentInput ) {
405388 return ;
406389 }
407390
408- const nextInput = withCurrentViewOptions ( bootstrap . input , {
409- layoutMode,
410- themeId,
411- showAgentNotes,
412- showHunkHeaders,
413- showLineNumbers,
414- wrapLines,
415- } ) ;
416-
417- await onReloadSession ( nextInput , {
391+ await onReloadSession ( bootstrap . input , {
418392 resetApp : false ,
419393 sourcePath :
420394 bootstrap . input . kind === "vcs" ||
@@ -423,18 +397,7 @@ export function App({
423397 ? bootstrap . changeset . sourceLabel
424398 : undefined ,
425399 } ) ;
426- } , [
427- bootstrap . changeset . sourceLabel ,
428- bootstrap . input ,
429- canRefreshCurrentInput ,
430- layoutMode ,
431- onReloadSession ,
432- showAgentNotes ,
433- showHunkHeaders ,
434- showLineNumbers ,
435- themeId ,
436- wrapLines ,
437- ] ) ;
400+ } , [ bootstrap . changeset . sourceLabel , bootstrap . input , canRefreshCurrentInput , onReloadSession ] ) ;
438401
439402 const triggerRefreshCurrentInput = useCallback ( ( ) => {
440403 void refreshCurrentInput ( ) . catch ( ( error ) => {
@@ -527,8 +490,28 @@ export function App({
527490 const cycleTheme = useCallback ( ( ) => {
528491 const currentIndex = THEMES . findIndex ( ( theme ) => theme . id === activeTheme . id ) ;
529492 const nextIndex = ( currentIndex + 1 ) % THEMES . length ;
530- setThemeId ( THEMES [ nextIndex ] ! . id ) ;
531- } , [ activeTheme . id ] ) ;
493+ updateView ( { themeId : THEMES [ nextIndex ] ! . id } ) ;
494+ } , [ activeTheme . id , updateView ] ) ;
495+
496+ /** Set the theme directly from the theme menu. */
497+ const selectThemeId = useCallback (
498+ ( id : string ) => {
499+ updateView ( { themeId : id } ) ;
500+ } ,
501+ [ updateView ] ,
502+ ) ;
503+
504+ /** Advance the commit-details mode through full → compact → hidden → full. */
505+ const cycleCommitDetailsMode = useCallback ( ( ) => {
506+ updateView ( {
507+ commitDetailsMode :
508+ commitDetailsMode === "full"
509+ ? "compact"
510+ : commitDetailsMode === "compact"
511+ ? "hidden"
512+ : "full" ,
513+ } ) ;
514+ } , [ commitDetailsMode , updateView ] ) ;
532515
533516 const menus = useMemo (
534517 ( ) =>
@@ -543,15 +526,15 @@ export function App({
543526 refreshCurrentInput : triggerRefreshCurrentInput ,
544527 requestQuit,
545528 selectLayoutMode,
546- selectThemeId : setThemeId ,
529+ selectThemeId,
547530 showAgentNotes,
548531 showHelp,
549532 showHunkHeaders,
550533 showLineNumbers,
551534 commitDetailsMode : isCommitReview ? commitDetailsMode : undefined ,
552535 sidebarVisible,
553536 toggleAgentNotes,
554- cycleCommitDetailsMode : isCommitReview ? onCycleCommitDetailsMode : undefined ,
537+ cycleCommitDetailsMode : isCommitReview ? cycleCommitDetailsMode : undefined ,
555538 moveToCommit : isCommitReview && onMoveCommit ? onMoveCommit : undefined ,
556539 toggleFocusArea,
557540 toggleHelp,
@@ -571,15 +554,16 @@ export function App({
571554 requestQuit ,
572555 review . moveToHunk ,
573556 selectLayoutMode ,
557+ selectThemeId ,
574558 triggerRefreshCurrentInput ,
575559 showAgentNotes ,
576560 showHelp ,
577561 showHunkHeaders ,
578562 showLineNumbers ,
579563 commitDetailsMode ,
564+ cycleCommitDetailsMode ,
580565 isCommitReview ,
581566 onMoveCommit ,
582- onCycleCommitDetailsMode ,
583567 sidebarVisible ,
584568 toggleAgentNotes ,
585569 toggleFocusArea ,
@@ -630,7 +614,7 @@ export function App({
630614 showHelp,
631615 switchMenu,
632616 toggleAgentNotes,
633- cycleCommitDetailsMode : isCommitReview ? onCycleCommitDetailsMode : undefined ,
617+ cycleCommitDetailsMode : isCommitReview ? cycleCommitDetailsMode : undefined ,
634618 toggleFocusArea,
635619 toggleHelp,
636620 toggleHunkHeaders,
0 commit comments