Skip to content

Commit 9099934

Browse files
committed
feat: remember scroll position for terminal
1 parent e8b2ff0 commit 9099934

1 file changed

Lines changed: 106 additions & 16 deletions

File tree

apps/desktop/src/hooks/useAppState.ts

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface TerminalRuntime extends TerminalRendererRuntime {
5050
scrollDisposable?: { dispose: () => void };
5151
viewportEl?: HTMLDivElement | null;
5252
viewportHandler?: (() => void) | null;
53+
viewportRestoreRaf?: number;
5354
activationRaf?: number;
5455
rendererAttachRaf?: number;
5556
webglPostInitTimer?: number;
@@ -305,7 +306,7 @@ export function useAppState(
305306
const [config, setConfig] = useState<AppConfig>(defaultConfig);
306307
const [hasSavedConfig, setHasSavedConfig] = useState<boolean | null>(null);
307308
const [sessions, setSessions] = useState<Session[]>([]);
308-
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
309+
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(null);
309310
const [filter, setFilter] = useState("");
310311
const [unreadOutput, setUnreadOutput] = useState<Record<string, boolean>>({});
311312
const [agentOutputting, setAgentOutputting] = useState<Record<string, boolean>>({});
@@ -492,6 +493,9 @@ export function useAppState(
492493
const setFollowingState = useCallback(
493494
(runtime: TerminalRuntime, sessionId: string, kind: PaneKind, isFollowing: boolean) => {
494495
runtime.isFollowing = isFollowing;
496+
if (isFollowing) {
497+
runtime.savedViewportY = undefined;
498+
}
495499
setUnreadFor(sessionId, kind, !isFollowing);
496500
},
497501
[setUnreadFor]
@@ -509,6 +513,65 @@ export function useAppState(
509513
[setFollowingState]
510514
);
511515

516+
const snapshotRuntimeViewport = useCallback((runtime: TerminalRuntime) => {
517+
const viewportY = runtime.term?.buffer.active.viewportY;
518+
if (runtime.isFollowing === false && viewportY !== undefined) {
519+
runtime.savedViewportY = viewportY;
520+
return;
521+
}
522+
runtime.savedViewportY = undefined;
523+
}, []);
524+
525+
const snapshotActiveRuntimeViewport = useCallback(() => {
526+
const sessionId = activeSessionRef.current;
527+
const kind = activePaneKindRef.current;
528+
if (!sessionId) {
529+
return;
530+
}
531+
const runtime = runtimeRef.current.get(sessionId)?.[kind];
532+
if (!runtime?.term) {
533+
return;
534+
}
535+
updateFollowState(runtime, sessionId, kind, runtime.viewportEl ?? undefined);
536+
snapshotRuntimeViewport(runtime);
537+
}, [snapshotRuntimeViewport, updateFollowState]);
538+
539+
const restoreRuntimeViewport = useCallback(
540+
(runtime: TerminalRuntime, sessionId: string, kind: PaneKind) => {
541+
if (!runtime.term || runtime.isFollowing !== false || runtime.savedViewportY === undefined) {
542+
return;
543+
}
544+
if (runtime.viewportRestoreRaf !== undefined) {
545+
window.cancelAnimationFrame(runtime.viewportRestoreRaf);
546+
}
547+
const retryRestore = () => {
548+
runtime.viewportRestoreRaf = undefined;
549+
const term = runtime.term;
550+
const savedViewportY = runtime.savedViewportY;
551+
if (!term || savedViewportY === undefined || runtime.isFollowing !== false) {
552+
return;
553+
}
554+
if (!isRuntimeVisible(runtime)) {
555+
runtime.viewportRestoreRaf = window.requestAnimationFrame(retryRestore);
556+
return;
557+
}
558+
term.write("", () => {
559+
if (!runtime.term || runtime.savedViewportY !== savedViewportY || runtime.isFollowing !== false) {
560+
return;
561+
}
562+
if (!isRuntimeVisible(runtime)) {
563+
return;
564+
}
565+
runtime.term.scrollToLine(savedViewportY);
566+
runtime.savedViewportY = undefined;
567+
updateFollowState(runtime, sessionId, kind, runtime.viewportEl ?? undefined);
568+
});
569+
};
570+
runtime.viewportRestoreRaf = window.requestAnimationFrame(retryRestore);
571+
},
572+
[updateFollowState]
573+
);
574+
512575
const focusSession = useCallback((sessionId: string, kind: PaneKind) => {
513576
const runtime = runtimeRef.current.get(sessionId)?.[kind];
514577
if (runtime?.term) {
@@ -548,6 +611,16 @@ export function useAppState(
548611
activeSessionRef.current = activeSessionId;
549612
}, [activeSessionId]);
550613

614+
const setActiveSessionId = useCallback(
615+
(nextSessionId: string | null) => {
616+
if (activeSessionRef.current !== nextSessionId) {
617+
snapshotActiveRuntimeViewport();
618+
}
619+
setActiveSessionIdState(nextSessionId);
620+
},
621+
[snapshotActiveRuntimeViewport]
622+
);
623+
551624
const markConfigReady = useCallback(() => {
552625
configReadyResolveRef.current?.();
553626
configReadyResolveRef.current = null;
@@ -722,6 +795,20 @@ export function useAppState(
722795
[applyTerminalAppearanceToRuntime, toTerminalAppearance]
723796
);
724797

798+
const activateVisibleRuntime = useCallback(
799+
(
800+
runtime: TerminalRuntime,
801+
sessionId: string,
802+
kind: PaneKind,
803+
options?: { focus?: boolean; clearSelection?: boolean }
804+
) => {
805+
activateRuntime(runtime, options);
806+
scheduleTerminalFit(runtime, true);
807+
restoreRuntimeViewport(runtime, sessionId, kind);
808+
},
809+
[activateRuntime, restoreRuntimeViewport, scheduleTerminalFit]
810+
);
811+
725812
const refreshTerminalRenderersAfterFontLoad = useCallback(() => {
726813
applyTerminalAppearanceToAll(toTerminalAppearance(configRef.current.settings));
727814
}, [applyTerminalAppearanceToAll, toTerminalAppearance]);
@@ -1200,6 +1287,10 @@ export function useAppState(
12001287
window.cancelAnimationFrame(runtime.activationRaf);
12011288
runtime.activationRaf = undefined;
12021289
}
1290+
if (runtime.viewportRestoreRaf !== undefined) {
1291+
window.cancelAnimationFrame(runtime.viewportRestoreRaf);
1292+
runtime.viewportRestoreRaf = undefined;
1293+
}
12031294
runtime.container = null;
12041295
runtime.lastFit = undefined;
12051296
if (runtime.resizeObserver) {
@@ -1223,12 +1314,12 @@ export function useAppState(
12231314

12241315
const detachTerminalRuntime = useCallback(
12251316
(runtime: TerminalRuntime, preserveViewport = false) => {
1226-
if (preserveViewport && runtime.term) {
1227-
runtime.savedViewportY = runtime.term.buffer.active.viewportY;
1317+
if (preserveViewport) {
1318+
snapshotRuntimeViewport(runtime);
12281319
}
12291320
cleanupTerminalRuntimeAttachment(runtime);
12301321
},
1231-
[cleanupTerminalRuntimeAttachment]
1322+
[cleanupTerminalRuntimeAttachment, snapshotRuntimeViewport]
12321323
);
12331324

12341325
const disposeTerminalRuntime = useCallback(
@@ -1248,6 +1339,7 @@ export function useAppState(
12481339
runtime.starting = false;
12491340
runtime.isFollowing = undefined;
12501341
runtime.savedViewportY = undefined;
1342+
runtime.viewportRestoreRaf = undefined;
12511343
runtime.activationRaf = undefined;
12521344
},
12531345
[detachTerminalRuntime]
@@ -1264,22 +1356,24 @@ export function useAppState(
12641356

12651357
const setActivePaneKind = useCallback(
12661358
(kind: PaneKind) => {
1359+
if (activePaneKindRef.current !== kind) {
1360+
snapshotActiveRuntimeViewport();
1361+
}
12671362
activePaneKindRef.current = kind;
12681363
const sessionId = activeSessionRef.current;
12691364
if (!sessionId) {
12701365
return;
12711366
}
12721367
const runtime = runtimeRef.current.get(sessionId)?.[kind];
12731368
if (runtime?.term) {
1274-
activateRuntime(runtime, { focus: true, clearSelection: true });
1275-
scheduleTerminalFit(runtime);
1369+
activateVisibleRuntime(runtime, sessionId, kind, { focus: true, clearSelection: true });
12761370
return;
12771371
}
12781372
if (!focusSession(sessionId, kind)) {
12791373
pendingFocusRef.current = { sessionId, kind };
12801374
}
12811375
},
1282-
[activateRuntime, focusSession, scheduleTerminalFit]
1376+
[activateVisibleRuntime, focusSession, snapshotActiveRuntimeViewport]
12831377
);
12841378

12851379
const jumpToBottom = useCallback(
@@ -1290,7 +1384,7 @@ export function useAppState(
12901384
}
12911385
setFollowingState(runtime, sessionId, kind, true);
12921386
runtime.term.scrollToBottom();
1293-
scheduleTerminalFit(runtime);
1387+
scheduleTerminalFit(runtime, true);
12941388
},
12951389
[scheduleTerminalFit, setFollowingState]
12961390
);
@@ -1342,10 +1436,6 @@ export function useAppState(
13421436
viewport.addEventListener("scroll", handler, { passive: true });
13431437
handler();
13441438
}
1345-
if (runtime.savedViewportY !== undefined && runtime.isFollowing === false) {
1346-
term.scrollToLine(runtime.savedViewportY);
1347-
}
1348-
runtime.savedViewportY = undefined;
13491439
updateFollowState(runtime, sessionId, kind, runtime.viewportEl ?? undefined);
13501440

13511441
const isActiveRuntime = activeSessionRef.current === sessionId && activePaneKindRef.current === kind;
@@ -1358,7 +1448,7 @@ export function useAppState(
13581448
pendingFocusRef.current = null;
13591449
}
13601450
if (isActiveRuntime) {
1361-
activateRuntime(runtime, { focus: true, clearSelection: true });
1451+
activateVisibleRuntime(runtime, sessionId, kind, { focus: true, clearSelection: true });
13621452
} else {
13631453
activateRuntime(runtime, { focus: false, clearSelection: false });
13641454
}
@@ -1407,6 +1497,7 @@ export function useAppState(
14071497
updateFollowState,
14081498
notify,
14091499
activateRuntime,
1500+
activateVisibleRuntime,
14101501
]
14111502
);
14121503

@@ -1925,14 +2016,13 @@ export function useAppState(
19252016
const kind = activePaneKindRef.current;
19262017
const runtime = runtimeRef.current.get(sessionId)?.[kind];
19272018
if (runtime?.term) {
1928-
activateRuntime(runtime, { focus: true, clearSelection: true });
1929-
scheduleTerminalFit(runtime);
2019+
activateVisibleRuntime(runtime, sessionId, kind, { focus: true, clearSelection: true });
19302020
return;
19312021
}
19322022
if (!focusSession(sessionId, kind)) {
19332023
pendingFocusRef.current = { sessionId, kind };
19342024
}
1935-
}, [activeSessionId, activateRuntime, focusSession, scheduleTerminalFit]);
2025+
}, [activeSessionId, activateVisibleRuntime, focusSession]);
19362026

19372027
const handleCloseRequest = useCallback(async () => {
19382028
if (closeInProgressRef.current || closePromptInProgressRef.current) {

0 commit comments

Comments
 (0)