Skip to content

Commit dad6b82

Browse files
committed
fix(web): keep terminal wheel scroll in history
1 parent d66d8f2 commit dad6b82

4 files changed

Lines changed: 354 additions & 6 deletions

File tree

packages/app/src/web/terminal-panel-runtime.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
TerminalSocketConnectArgs,
2222
TerminalSocketRef
2323
} from "./terminal-panel-runtime-types.js"
24+
import { attachTerminalWheelScroll } from "./terminal-wheel-scroll.js"
2425
import { isPendingActiveTerminalSession } from "./terminal.js"
2526

2627
type TerminalDisposable = { readonly dispose: () => void }
@@ -34,6 +35,7 @@ type TerminalCleanupFactoryArgs = {
3435
readonly imageLinkDisposable: TerminalDisposable
3536
readonly imagePasteDisposable: TerminalDisposable
3637
readonly inputDisposable: TerminalDisposable
38+
readonly wheelScrollDisposable: TerminalDisposable
3739
readonly sendResize: () => void
3840
}
3941

@@ -44,7 +46,8 @@ const createTerminalCleanup = (
4446
imageLinkDisposable,
4547
imagePasteDisposable,
4648
inputDisposable,
47-
sendResize
49+
sendResize,
50+
wheelScrollDisposable
4851
}: TerminalCleanupFactoryArgs
4952
): () => void =>
5053
(): void => {
@@ -59,6 +62,7 @@ const createTerminalCleanup = (
5962
removeInput: () => {
6063
copyInteractionDisposable.dispose()
6164
inputDisposable.dispose()
65+
wheelScrollDisposable.dispose()
6266
},
6367
removeResize: () => {
6468
globalThis.removeEventListener("resize", sendResize)
@@ -103,6 +107,7 @@ type MountedTerminalDisposables = {
103107
readonly imageLinkDisposable: TerminalDisposable
104108
readonly imagePasteDisposable: TerminalDisposable
105109
readonly inputDisposable: TerminalDisposable
110+
readonly wheelScrollDisposable: TerminalDisposable
106111
}
107112

108113
type MountedTerminalCleanupArgs = {
@@ -131,7 +136,8 @@ const createMountedTerminalDisposables = (
131136
socketRef,
132137
terminal
133138
}),
134-
inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard)
139+
inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard),
140+
wheelScrollDisposable: attachTerminalWheelScroll({ host, terminal })
135141
})
136142

137143
const createMountedTerminalConnector = (
@@ -171,7 +177,8 @@ const createMountedTerminalCleanup = (
171177
imageLinkDisposable: disposables.imageLinkDisposable,
172178
imagePasteDisposable: disposables.imagePasteDisposable,
173179
inputDisposable: disposables.inputDisposable,
174-
sendResize
180+
sendResize,
181+
wheelScrollDisposable: disposables.wheelScrollDisposable
175182
})
176183

177184
const resolveMountHost = (
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
export type TerminalWheelMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10"
2+
3+
export type TerminalWheelScrollTerminal = {
4+
readonly modes: {
5+
readonly mouseTrackingMode: TerminalWheelMouseTrackingMode
6+
}
7+
readonly rows: number
8+
readonly scrollLines: (amount: number) => void
9+
}
10+
11+
type TerminalWheelScrollEvent = {
12+
readonly deltaMode: number
13+
readonly deltaY: number
14+
readonly stopImmediatePropagation?: () => void
15+
readonly preventDefault: () => void
16+
readonly stopPropagation: () => void
17+
}
18+
19+
type TerminalWheelScrollHost = {
20+
readonly addEventListener: (
21+
type: "wheel",
22+
listener: (event: TerminalWheelScrollEvent) => void,
23+
options: AddEventListenerOptions
24+
) => void
25+
readonly removeEventListener: (
26+
type: "wheel",
27+
listener: (event: TerminalWheelScrollEvent) => void,
28+
options: boolean
29+
) => void
30+
}
31+
32+
type TerminalWheelScrollDelta = {
33+
readonly deltaMode: number
34+
readonly deltaY: number
35+
readonly previousPixelDeltaY: number
36+
readonly rows: number
37+
}
38+
39+
export type ResolvedTerminalWheelScrollDelta = {
40+
readonly lines: number
41+
readonly nextPixelDeltaY: number
42+
}
43+
44+
type TerminalWheelScrollArgs = {
45+
readonly host: TerminalWheelScrollHost
46+
readonly terminal: TerminalWheelScrollTerminal
47+
}
48+
49+
const wheelPixelDeltaMode = 0
50+
const wheelLineDeltaMode = 1
51+
const wheelPageDeltaMode = 2
52+
const pixelsPerTerminalLine = 15
53+
54+
const hasActiveMouseTracking = (terminal: TerminalWheelScrollTerminal): boolean =>
55+
terminal.modes.mouseTrackingMode !== "none"
56+
57+
const validTerminalRows = (rows: number): number => {
58+
if (!Number.isFinite(rows) || rows < 1) {
59+
return 1
60+
}
61+
return Math.trunc(rows)
62+
}
63+
64+
const finiteDelta = (delta: number): number => {
65+
if (!Number.isFinite(delta)) {
66+
return 0
67+
}
68+
return delta
69+
}
70+
71+
export const resolveTerminalWheelScrollDelta = (
72+
delta: TerminalWheelScrollDelta
73+
): ResolvedTerminalWheelScrollDelta => {
74+
const deltaY = finiteDelta(delta.deltaY)
75+
if (delta.deltaMode === wheelLineDeltaMode) {
76+
return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 }
77+
}
78+
if (delta.deltaMode === wheelPageDeltaMode) {
79+
return { lines: Math.trunc(deltaY * validTerminalRows(delta.rows)), nextPixelDeltaY: 0 }
80+
}
81+
if (delta.deltaMode !== wheelPixelDeltaMode) {
82+
return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 }
83+
}
84+
const nextPixelDeltaY = finiteDelta(delta.previousPixelDeltaY) + deltaY
85+
const lines = Math.trunc(nextPixelDeltaY / pixelsPerTerminalLine)
86+
return {
87+
lines,
88+
nextPixelDeltaY: nextPixelDeltaY - lines * pixelsPerTerminalLine
89+
}
90+
}
91+
92+
export const attachTerminalWheelScroll = (
93+
args: TerminalWheelScrollArgs
94+
): { readonly dispose: () => void } => {
95+
let previousPixelDeltaY = 0
96+
const onWheel = (event: TerminalWheelScrollEvent): void => {
97+
if (!hasActiveMouseTracking(args.terminal)) {
98+
return
99+
}
100+
const scrollDelta = resolveTerminalWheelScrollDelta({
101+
deltaMode: event.deltaMode,
102+
deltaY: event.deltaY,
103+
previousPixelDeltaY,
104+
rows: args.terminal.rows
105+
})
106+
previousPixelDeltaY = scrollDelta.nextPixelDeltaY
107+
event.preventDefault()
108+
event.stopPropagation()
109+
event.stopImmediatePropagation?.()
110+
if (scrollDelta.lines !== 0) {
111+
args.terminal.scrollLines(scrollDelta.lines)
112+
}
113+
}
114+
115+
args.host.addEventListener("wheel", onWheel, { capture: true, passive: false })
116+
117+
return {
118+
dispose: () => {
119+
args.host.removeEventListener("wheel", onWheel, true)
120+
}
121+
}
122+
}

packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,15 @@ describe("terminal panel runtime core", () => {
8383
expect(sent).toEqual([JSON.stringify({ data: "a", type: "input" })])
8484
})
8585

86-
it("keeps the viewport stable for terminal mouse reports", () => {
86+
it("keeps the viewport stable for terminal mouse click reports", () => {
8787
const input = createTerminalInputHarness()
8888
const { sent, socketRef } = createOpenSocketRef()
8989

9090
attachTerminalInput(input.terminal, socketRef, passThroughPasteGuard)
91-
input.emit("\u001B[<64;10;5M")
91+
input.emit("\u001B[<0;10;5M")
9292

9393
expect(input.state.scrolls).toBe(0)
94-
expect(sent).toEqual([JSON.stringify({ data: "\u001B[<64;10;5M", type: "input" })])
94+
expect(sent).toEqual([JSON.stringify({ data: "\u001B[<0;10;5M", type: "input" })])
9595
})
9696

9797
it("does not scroll or send input suppressed by the paste guard", () => {

0 commit comments

Comments
 (0)