Skip to content

Commit 9c74ec5

Browse files
committed
feat(pager): persist all view preferences across commit-cursor moves
Previously only the commit-details mode survived the App remount that fires on every commit-cursor move. The other seven view toggles — layout mode, theme, sidebar visibility, agent notes, line numbers, line wrapping, hunk metadata — re-initialised from bootstrap defaults on each remount, so any user adjustment was lost the moment they pressed > or <. Lift the bundle into AppHost as a single ViewPreferences record alongside the commit-details mode. App receives view + updateView (a partial-merge setter) and reads/writes through them; the seven local useState calls are gone. The withCurrentViewOptions reload helper is also obsolete: the prefs now live above the App's lifecycle, so reload doesn't need to round-trip them through the bootstrap. Adding the next view toggle is a one-line type extension rather than another (value, setter) prop pair on App.
1 parent 778ebbb commit 9c74ec5

3 files changed

Lines changed: 112 additions & 102 deletions

File tree

src/core/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ export interface CommonOptions {
101101
*/
102102
export type CommitDetailsMode = "full" | "compact" | "hidden";
103103

104+
/**
105+
* Live view-preference bundle owned by AppHost so the toggles a user has set
106+
* survive App remounts (which fire whenever the commit cursor moves between
107+
* commits). Updates are applied as partials through `updateView`. This is the
108+
* runtime shape; the persisted-on-disk variant is `PersistedViewPreferences`
109+
* below and uses CLI/config field names rather than the internal ones.
110+
*/
111+
export interface ViewPreferences {
112+
layoutMode: LayoutMode;
113+
themeId: string;
114+
showAgentNotes: boolean;
115+
showLineNumbers: boolean;
116+
wrapLines: boolean;
117+
showHunkHeaders: boolean;
118+
sidebarVisible: boolean;
119+
commitDetailsMode: CommitDetailsMode;
120+
}
121+
104122
export interface PersistedViewPreferences {
105123
mode: LayoutMode;
106124
theme?: string;

src/ui/App.tsx

Lines changed: 69 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { useRenderer, useTerminalDimensions } from "@opentui/react";
77
import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react";
88
import 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";
1010
import { canReloadInput, computeWatchSignature } from "../core/watch";
1111
import 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. */
8054
export 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,

src/ui/AppHost.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import type { Dispatch, SetStateAction } from "react";
3+
import { useRenderer } from "@opentui/react";
34
import { resolveConfiguredCliInput } from "../core/config";
45
import { loadAppBootstrap } from "../core/loaders";
56
import { resolveRuntimeCliInput } from "../core/terminal";
67
import type {
78
AppBootstrap,
89
CliInput,
910
CommitChangeset,
10-
CommitDetailsMode,
1111
DiffFile,
12+
ViewPreferences,
1213
} from "../core/types";
1314
import type { UpdateNotice } from "../core/updateNotice";
1415
import {
@@ -18,6 +19,7 @@ import {
1819
import type { HunkSessionBrokerClient, LiveComment } from "../hunk-session/types";
1920
import { App } from "./App";
2021
import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice";
22+
import { resolveTheme } from "./themes";
2123

2224
/** Result returned by `onMoveCommit` so the caller can detect blocked moves. */
2325
export type MoveCommitResult =
@@ -46,20 +48,26 @@ export function AppHost({
4648
const [commitBuffer, setCommitBuffer] = useState<CommitChangeset[]>([]);
4749
const [cursorIndex, setCursorIndex] = useState(0);
4850
const [commitStreamComplete, setCommitStreamComplete] = useState(false);
49-
// The commit-details view mode and live-comment store both live at AppHost so they
50-
// survive the App remount that fires on every commit-cursor move. App's view-state
51-
// reset on commit nav is intentional for selection / scroll / filter (they don't
52-
// translate across commits), but the user's metadata-visibility preference and the
53-
// notes they've left should follow them. Live comments are bucketed by the active
54-
// changeset's id so each commit (or each non-commit-review canvas) keeps its own
55-
// annotation set; switching back to a previously visited commit restores its notes.
56-
const [commitDetailsMode, setCommitDetailsMode] = useState<CommitDetailsMode>(
57-
bootstrap.initialCommitDetailsMode ?? "full",
58-
);
59-
const cycleCommitDetailsMode = useCallback(() => {
60-
setCommitDetailsMode((current) =>
61-
current === "full" ? "compact" : current === "compact" ? "hidden" : "full",
62-
);
51+
// View preferences and the live-comment store both live at AppHost so they survive
52+
// the App remount that fires on every commit-cursor move. App's view-state reset on
53+
// commit nav is intentional for selection / scroll / filter (they don't translate
54+
// across commits), but the toggles a user has set and the notes they've left should
55+
// follow them. Live comments are bucketed by the active changeset's id so each commit
56+
// (or each non-commit-review canvas) keeps its own annotation set; switching back to
57+
// a previously visited commit restores its notes.
58+
const renderer = useRenderer();
59+
const [view, setView] = useState<ViewPreferences>(() => ({
60+
layoutMode: bootstrap.initialMode,
61+
themeId: resolveTheme(bootstrap.initialTheme, renderer.themeMode).id,
62+
showAgentNotes: bootstrap.initialShowAgentNotes ?? false,
63+
showLineNumbers: bootstrap.initialShowLineNumbers ?? true,
64+
wrapLines: bootstrap.initialWrapLines ?? false,
65+
showHunkHeaders: bootstrap.initialShowHunkHeaders ?? true,
66+
sidebarVisible: true,
67+
commitDetailsMode: bootstrap.initialCommitDetailsMode ?? "full",
68+
}));
69+
const updateView = useCallback((patch: Partial<ViewPreferences>) => {
70+
setView((current) => ({ ...current, ...patch }));
6371
}, []);
6472
const [liveCommentsByReviewKey, setLiveCommentsByReviewKey] = useState<
6573
Record<string, Record<string, LiveComment[]>>
@@ -295,8 +303,8 @@ export function AppHost({
295303
onQuit={onQuit}
296304
onReloadSession={reloadSession}
297305
onMoveCommit={bootstrap.commitReviewStream ? onMoveCommit : undefined}
298-
commitDetailsMode={commitDetailsMode}
299-
onCycleCommitDetailsMode={bootstrap.commitReviewStream ? cycleCommitDetailsMode : undefined}
306+
view={view}
307+
updateView={updateView}
300308
liveCommentsByFileId={liveCommentsByFileId}
301309
setLiveCommentsByFileId={setLiveCommentsByFileId}
302310
/>

0 commit comments

Comments
 (0)