Skip to content

Commit cfbdc25

Browse files
authored
keep the terminal scrolled to the bottom across resizes if it was at the bottom (#2941)
1 parent 296760d commit cfbdc25

2 files changed

Lines changed: 47 additions & 0 deletions

File tree

frontend/app/view/term/term.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
311311
model.termRef.current = termWrap;
312312
setTermWrapInst(termWrap);
313313
const rszObs = new ResizeObserver(() => {
314+
if (termWrap.cachedAtBottomForResize == null) {
315+
termWrap.cachedAtBottomForResize = termWrap.wasRecentlyAtBottom();
316+
}
314317
termWrap.handleResize_debounced();
315318
});
316319
rszObs.observe(connectElemRef.current);

frontend/app/view/term/termwrap.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export class TermWrap {
104104
// xterm.js paste() method triggers onData event, which can cause duplicate sends
105105
lastPasteData: string = "";
106106
lastPasteTime: number = 0;
107+
lastAtBottomTime: number = Date.now();
108+
lastScrollAtBottom: boolean = true;
109+
cachedAtBottomForResize: boolean | null = null;
107110

108111
constructor(
109112
tabId: string,
@@ -226,6 +229,19 @@ export class TermWrap {
226229
this.connectElem.removeEventListener("paste", pasteHandler, true);
227230
},
228231
});
232+
const viewportElem = this.connectElem.querySelector(".xterm-viewport") as HTMLElement;
233+
if (viewportElem) {
234+
const scrollHandler = () => {
235+
const atBottom = viewportElem.scrollTop + viewportElem.clientHeight >= viewportElem.scrollHeight - 20;
236+
this.setAtBottom(atBottom);
237+
};
238+
viewportElem.addEventListener("scroll", scrollHandler);
239+
this.toDispose.push({
240+
dispose: () => {
241+
viewportElem.removeEventListener("scroll", scrollHandler);
242+
},
243+
});
244+
}
229245
}
230246

231247
getZoneId(): string {
@@ -465,9 +481,30 @@ export class TermWrap {
465481
}
466482
}
467483

484+
setAtBottom(atBottom: boolean) {
485+
if (this.lastScrollAtBottom && !atBottom) {
486+
this.lastAtBottomTime = Date.now();
487+
}
488+
this.lastScrollAtBottom = atBottom;
489+
if (atBottom) {
490+
this.lastAtBottomTime = Date.now();
491+
}
492+
}
493+
494+
wasRecentlyAtBottom(): boolean {
495+
if (this.lastScrollAtBottom) {
496+
return true;
497+
}
498+
return Date.now() - this.lastAtBottomTime <= 1000;
499+
}
500+
468501
handleResize() {
469502
const oldRows = this.terminal.rows;
470503
const oldCols = this.terminal.cols;
504+
const atBottom = this.cachedAtBottomForResize ?? this.wasRecentlyAtBottom();
505+
if (!atBottom) {
506+
this.cachedAtBottomForResize = null;
507+
}
471508
this.fitAddon.fit();
472509
if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {
473510
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
@@ -478,6 +515,13 @@ export class TermWrap {
478515
this.hasResized = true;
479516
this.resyncController("initial resize");
480517
}
518+
if (atBottom) {
519+
setTimeout(() => {
520+
this.cachedAtBottomForResize = null;
521+
this.terminal.scrollToBottom();
522+
this.setAtBottom(true);
523+
}, 20);
524+
}
481525
}
482526

483527
processAndCacheData() {

0 commit comments

Comments
 (0)