mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6762324d | ||
|
|
948ecaa877 | ||
|
|
5e7e2420f8 | ||
|
|
2c94043f2e | ||
|
|
0e8b404a09 | ||
|
|
d56b3d4194 | ||
|
|
81313a6ef4 | ||
|
|
673f306260 | ||
|
|
3d560485fc | ||
|
|
e130305d61 | ||
|
|
0932146777 | ||
|
|
ef51bcc5af | ||
|
|
cc773bceb8 | ||
|
|
b0ac1b3180 | ||
|
|
36746f7339 | ||
|
|
bc50bad783 | ||
|
|
47d89c6960 | ||
|
|
c5a9fb0223 | ||
|
|
e3c61388ee | ||
|
|
0dd38fa5e4 | ||
|
|
456ac6af2b | ||
|
|
21cdd7953b | ||
|
|
a2532620d0 | ||
|
|
87057e3569 | ||
|
|
6301fe519b |
No files matched your search
@@ -252,6 +252,8 @@ import {
|
||||
shouldStartTerminalBackend,
|
||||
} from "./terminal/restoredSessionGate";
|
||||
import {
|
||||
alignTerminalViewportScroll,
|
||||
createSynchronizedOutputFitScheduler,
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
forceSyncRenderAfterResize,
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
@@ -2274,6 +2276,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearHibernateRetry();
|
||||
clearAutoReconnect();
|
||||
void cleanupSession();
|
||||
synchronizedFitSchedulerRef.current?.dispose();
|
||||
disposeRuntimeOnly();
|
||||
};
|
||||
|
||||
@@ -2867,6 +2870,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
||||
|
||||
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
|
||||
if (!synchronizedFitSchedulerRef.current) {
|
||||
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
|
||||
}
|
||||
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
|
||||
|
||||
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
|
||||
const pendingWriteSafeFitRef = useRef<{
|
||||
term: XTerm;
|
||||
@@ -2964,6 +2973,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the frozen buffer intact until synchronized output finishes.
|
||||
// Re-enter safeFit to use the latest size, buffer and reading position.
|
||||
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
|
||||
if (termRef.current === term) {
|
||||
safeFitRef.current({ ...options, force: true, immediate: true });
|
||||
}
|
||||
})) return;
|
||||
|
||||
const buffer = term.buffer.active;
|
||||
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
|
||||
const savedViewportY = buffer.viewportY;
|
||||
@@ -2988,6 +3005,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
forceSyncRenderAfterResize(term);
|
||||
}
|
||||
|
||||
// Reflow changes the buffer row before xterm updates its scroll range.
|
||||
// Align first so the relative restore below does not apply that delta twice.
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
// Preserve scroll position across resize (superset/Tabby pattern).
|
||||
if (wasPinnedToBottom) {
|
||||
term.scrollToBottom();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { alignTerminalViewportScroll } from "./terminalHelpers";
|
||||
|
||||
type ViewportSpy = {
|
||||
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
|
||||
_sync?: () => void;
|
||||
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
|
||||
};
|
||||
|
||||
const createTerm = (
|
||||
viewportY: number,
|
||||
viewport?: ViewportSpy | null,
|
||||
): XTerm => ({
|
||||
buffer: { active: { viewportY } },
|
||||
_core: viewport === null ? undefined : { _viewport: viewport },
|
||||
}) as unknown as XTerm;
|
||||
|
||||
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const order: string[] = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
order.push("sync");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
order.push("scrollToLine");
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(order, ["sync", "scrollToLine"]);
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
|
||||
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
|
||||
const term = createTerm(10, null);
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: { _viewport: {} },
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: {
|
||||
_viewport: {
|
||||
scrollToLine: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
@@ -539,3 +539,84 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
|
||||
logger.warn("Sync render after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
type XTermWithPrivateViewport = XTerm & {
|
||||
_core?: {
|
||||
_viewport?: {
|
||||
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
|
||||
_sync?: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-align the DOM scroll position with the buffer's viewport row.
|
||||
*
|
||||
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
|
||||
* the scrollable viewport keeps its stale pixel offset. Any subsequent
|
||||
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
|
||||
* against the buffer and once against the stale DOM offset — drifting the
|
||||
* reading position (all the way to the top while shrinking, #3299). Snapping
|
||||
* the viewport back to the buffer row before a relative restore removes the
|
||||
* desync.
|
||||
*/
|
||||
export function alignTerminalViewportScroll(term: XTerm): void {
|
||||
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
|
||||
const scrollToLine = viewport?.scrollToLine;
|
||||
if (typeof scrollToLine !== "function") return;
|
||||
|
||||
// After a resize, xterm only refreshes the viewport's scroll dimensions on
|
||||
// its queued render callback. Setting a scroll position against the stale
|
||||
// dimensions gets clamped to the old maximum while xterm records the
|
||||
// requested row, so the queued sync then assumes the position was already
|
||||
// applied and the DOM offset stays stale — the next wheel scroll jumps
|
||||
// upward by the resize delta. Sync the dimensions now, before positioning.
|
||||
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
|
||||
// that merely defers DOM scroll updates until the mode ends; positioning
|
||||
// here would still record the requested row as _latestYDisp against the
|
||||
// stale dimensions, and the deferred sync would then see
|
||||
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
|
||||
// offset. Leave positioning to that deferred sync instead: after reflow the
|
||||
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
|
||||
// with fresh dimensions on its own.
|
||||
if (typeof viewport._sync === "function") {
|
||||
try {
|
||||
viewport._sync.call(viewport);
|
||||
} catch (err) {
|
||||
logger.warn("Sync viewport dimensions after resize failed", err);
|
||||
}
|
||||
}
|
||||
if (term.modes?.synchronizedOutputMode) return;
|
||||
|
||||
try {
|
||||
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
|
||||
} catch (err) {
|
||||
logger.warn("Align viewport scroll after resize failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
|
||||
export function createSynchronizedOutputFitScheduler() {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const dispose = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
return {
|
||||
dispose,
|
||||
defer(term: XTerm, fit: () => void): boolean {
|
||||
dispose();
|
||||
if (!term.modes.synchronizedOutputMode) return false;
|
||||
// Arms xterm's own synchronized-output timeout even if the program
|
||||
// only enabled the mode without writing visible content afterward.
|
||||
term.refresh(0, Math.max(0, term.rows - 1));
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
// The caller re-enters safeFit and checks the mode again, including
|
||||
// when another synchronized frame started before this retry.
|
||||
fit();
|
||||
}, 32);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console */
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("fit preserves terminal reading position", {
|
||||
skip: "run with Electron for real xterm viewport coverage",
|
||||
}, () => {});
|
||||
} else {
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("fit-scroll")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
const source = fs.readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
|
||||
const start = source.indexOf(" const safeFit = (options?: SafeFitOptions) => {");
|
||||
const end = source.indexOf(" const prevIsResizingRef", start);
|
||||
if (start < 0 || end < 0) throw new Error("Cannot extract production safeFit");
|
||||
const safeFitSource = esbuild.transformSync(source.slice(start, end), { loader: "ts" }).code;
|
||||
const bundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: 'export {Terminal} from "@xterm/xterm"; export {alignTerminalViewportScroll, forceSyncRenderAfterResize, createSynchronizedOutputFitScheduler} from "./components/terminal/terminalHelpers";',
|
||||
loader: "ts", resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "cjs", platform: "browser", write: false,
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
let win;
|
||||
const cleanup = code => {
|
||||
win?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
void app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({
|
||||
show: true, width: 1000, height: 700,
|
||||
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
|
||||
});
|
||||
await win.loadURL("data:text/html,<body style='background:%23111;color:white'><h3>Issue 3299: resize reading position</h3><div id='terminal'></div>");
|
||||
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
|
||||
const result = await win.webContents.executeJavaScript(`(async () => {
|
||||
const assert = require('node:assert/strict');
|
||||
const loaded = {exports:{}};
|
||||
((module,exports)=>{${bundle}})(loaded,loaded.exports);
|
||||
const {Terminal,forceSyncRenderAfterResize,createSynchronizedOutputFitScheduler} = loaded.exports;
|
||||
const alignTerminalViewportScroll = process.env.NETCATTY_FIT_BASELINE ? ()=>{} : loaded.exports.alignTerminalViewportScroll;
|
||||
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const term = new Terminal({cols:80,rows:25,scrollback:1000,fontSize:14,allowProposedApi:true,smoothScrollDuration:0});
|
||||
const el = document.getElementById('terminal');
|
||||
el.style.cssText='width:900px;height:560px'; term.open(el);
|
||||
const write = data => new Promise(resolve=>term.write(data,resolve));
|
||||
const ref = current => ({current});
|
||||
const termRef=ref(term), containerRef=ref(el), isRendererActiveRef=ref(true), lastFittedSizeRef=ref(null);
|
||||
const autocompleteRepositionRef=ref(null), xtermRuntimeRef=ref(null), pendingWriteSafeFitRef=ref(null);
|
||||
const synchronizedFitSchedulerRef=ref(createSynchronizedOutputFitScheduler());
|
||||
let dimensions={cols:80,rows:25};
|
||||
const fitAddonRef=ref({proposeDimensions:()=>dimensions});
|
||||
const hasPendingTerminalWrites=()=>false;
|
||||
const XTERM_PERFORMANCE_CONFIG={resize:{useRAF:false}};
|
||||
const logger={warn:(message,error)=>{throw error || new Error(message)}};
|
||||
${safeFitSource}
|
||||
const safeFitRef=ref(safeFit);
|
||||
const fit=(cols,rows)=>{dimensions={cols,rows};safeFit({force:true,immediate:true});};
|
||||
const domRow=()=>term._core._viewport._scrollableElement.getScrollPosition().scrollTop / term._core._renderService.dimensions.css.cell.height;
|
||||
const check=(expected,label)=>{
|
||||
assert.equal(term.buffer.active.viewportY,expected,label+' buffer');
|
||||
assert.ok(Math.abs(domRow()-expected)<0.01,label+' DOM expected '+expected+' actual '+domRow());
|
||||
};
|
||||
await write(Array.from({length:200},(_,i)=>'line '+i+' '+('A B repeated '.repeat(7))).join('\\r\\n'));
|
||||
await wait(100); term.scrollToLine(70); await wait(50);
|
||||
fit(80,16); await wait(80); check(70,'shrink');
|
||||
fit(80,30); await wait(80); check(70,'grow');
|
||||
for(let rows=29;rows>=12;rows--){fit(80,rows);await wait(20);check(70,'drag '+rows);}
|
||||
term.scrollToBottom();await wait(50);fit(80,8);await wait(80);check(term.buffer.active.baseY,'pinned shrink');
|
||||
term.scrollLines(-1);await wait(50);check(term.buffer.active.baseY-1,'first scroll after shrink');
|
||||
term.scrollToLine(70);await wait(50);
|
||||
const oldSize=[term.cols,term.rows];
|
||||
await write('\\x1b[?2026h');fit(100,20);fit(120,24);await wait(80);
|
||||
assert.deepEqual([term.cols,term.rows],oldSize,'no reflow inside synchronized frame');
|
||||
term.scrollLines(-3);await wait(40);const selected=term.buffer.active.viewportY;
|
||||
await write('\\x1b[?2026l');await wait(120);
|
||||
assert.deepEqual([term.cols,term.rows],[120,24]);check(Math.min(selected,term.buffer.active.baseY),'user selected position');
|
||||
await write('\\x1b[?2026h');fit(90,18);await write('\\x1b[?2026l\\x1b[?2026h');await wait(70);
|
||||
assert.equal(term.cols,120,'a new synchronized frame delays retry');
|
||||
await write('\\x1b[?2026l');await wait(100);check(Math.min(selected,term.buffer.active.baseY),'mode reentry');
|
||||
await write('\\r\\n'+Array.from({length:1200},()=> 'A B repeated').join('\\r\\n'));
|
||||
await wait(80);term.scrollToLine(100);await wait(30);
|
||||
await write('\\x1b[?2026h');fit(130,22);await write('\\r\\n'.repeat(5));
|
||||
const afterTrim=term.buffer.active.viewportY;
|
||||
await write('\\x1b[?2026l');await wait(100);check(afterTrim,'scrollback trim');
|
||||
await write('\\x1b[?2026h\\x1b[?1049h');fit(100,20);
|
||||
await write('alternate output\\x1b[?1049l\\x1b[?2026l');await wait(100);
|
||||
check(Math.min(afterTrim,term.buffer.active.baseY),'alternate buffer round trip');
|
||||
await write('\\x1b[?2026h');fit(110,21);await wait(1400);
|
||||
assert.equal(term.modes.synchronizedOutputMode,false,'xterm timeout releases output');
|
||||
assert.equal(term.cols,110,'fit retried after output timeout');
|
||||
const beforeDispose=term.cols;
|
||||
await write('\\x1b[?2026h');fit(140,25);synchronizedFitSchedulerRef.current.dispose();
|
||||
await write('\\x1b[?2026l');await wait(100);assert.equal(term.cols,beforeDispose,'teardown cancels retry');
|
||||
assert.ok(el.querySelector('.xterm-screen').getBoundingClientRect().height > 0,'real rendered terminal');
|
||||
return {passed:['shrink','grow','18 drag steps','bottom and next scroll','synchronized repeated spaced text','user scroll','mode reentry','full scrollback trim','alternate buffer','output timeout','cleanup'],viewportY:term.buffer.active.viewportY,domRow:domRow()};
|
||||
})()`);
|
||||
console.log(JSON.stringify(result));
|
||||
if (process.env.NETCATTY_FIT_SCREENSHOT) {
|
||||
fs.writeFileSync(process.env.NETCATTY_FIT_SCREENSHOT, (await win.webContents.capturePage()).toPNG());
|
||||
}
|
||||
cleanup(0);
|
||||
}).catch(error=>{console.error(error);cleanup(1);});
|
||||
}
|
||||
Reference in new issue
Block a user