mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 01:50:47 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82100bdc10 |
No files matched your search
@@ -10,9 +10,11 @@ import {
|
||||
formatTerminalTitleConnectionAddress,
|
||||
focusTerminalFromDisconnectedNotice,
|
||||
getLineTimestampToggleHostUpdate,
|
||||
resolveLineTimestampGutterLayout,
|
||||
resolveNetworkDeviceTipRightInset,
|
||||
resolveTerminalRightInset,
|
||||
resolveTerminalTopOffsets,
|
||||
subscribeTerminalAlternateScreenChanges,
|
||||
shouldBlockTerminalReconnectForTarget,
|
||||
shouldEnableStatusBarDisconnect,
|
||||
shouldEnableStatusBarReconnect,
|
||||
@@ -182,6 +184,127 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
|
||||
});
|
||||
|
||||
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: true,
|
||||
alternateScreenActive: true,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: false, width: 0 });
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: true,
|
||||
alternateScreenActive: false,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: true, width: 72 });
|
||||
assert.deepEqual(resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter: false,
|
||||
alternateScreenActive: false,
|
||||
gutterWidth: 72,
|
||||
}), { enabled: false, width: 0 });
|
||||
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
|
||||
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
|
||||
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
|
||||
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
|
||||
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
|
||||
});
|
||||
|
||||
type AlternateScreenWatchTerm = {
|
||||
buffer: { active: { type: string } };
|
||||
onRender: (callback: () => void) => { dispose: () => void };
|
||||
};
|
||||
|
||||
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
|
||||
const renders: Array<() => void> = [];
|
||||
let disposed = 0;
|
||||
const term: AlternateScreenWatchTerm = {
|
||||
buffer: { active: { type } },
|
||||
onRender(callback) {
|
||||
renders.push(callback);
|
||||
return { dispose: () => { disposed += 1; } };
|
||||
},
|
||||
};
|
||||
return {
|
||||
term,
|
||||
renders,
|
||||
disposeCount: () => disposed,
|
||||
};
|
||||
}
|
||||
|
||||
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
|
||||
const watched = createAlternateScreenWatchTerm("normal");
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => watched.term,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(stop);
|
||||
|
||||
assert.deepEqual(seen, [false]);
|
||||
watched.term.buffer.active.type = "alternate";
|
||||
watched.renders[0]();
|
||||
watched.renders[0]();
|
||||
watched.term.buffer.active.type = "normal";
|
||||
watched.renders[0]();
|
||||
assert.deepEqual(seen, [false, true, false]);
|
||||
|
||||
stop();
|
||||
assert.equal(watched.disposeCount(), 1);
|
||||
watched.term.buffer.active.type = "alternate";
|
||||
watched.renders[0]();
|
||||
assert.deepEqual(seen, [false, true, false]);
|
||||
});
|
||||
|
||||
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const watched = createAlternateScreenWatchTerm("alternate");
|
||||
let current: AlternateScreenWatchTerm | null = null;
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => current,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(() => {
|
||||
stop();
|
||||
t.mock.timers.reset();
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [false]);
|
||||
current = watched.term;
|
||||
t.mock.timers.tick(50);
|
||||
assert.deepEqual(seen, [false, true]);
|
||||
assert.equal(watched.renders.length, 1);
|
||||
t.mock.timers.tick(500);
|
||||
assert.equal(watched.renders.length, 1);
|
||||
assert.deepEqual(seen, [false, true]);
|
||||
});
|
||||
|
||||
test("alternate screen watch follows a replacement terminal runtime", (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const first = createAlternateScreenWatchTerm("alternate");
|
||||
const second = createAlternateScreenWatchTerm("normal");
|
||||
let current: AlternateScreenWatchTerm = first.term;
|
||||
const seen: boolean[] = [];
|
||||
const stop = subscribeTerminalAlternateScreenChanges(
|
||||
() => current,
|
||||
(active) => { seen.push(active); },
|
||||
);
|
||||
t.after(() => {
|
||||
stop();
|
||||
t.mock.timers.reset();
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [true]);
|
||||
current = second.term;
|
||||
t.mock.timers.tick(250);
|
||||
assert.deepEqual(seen, [true, false]);
|
||||
assert.equal(first.disposeCount(), 1);
|
||||
assert.equal(second.renders.length, 1);
|
||||
second.term.buffer.active.type = "alternate";
|
||||
second.renders[0]();
|
||||
assert.deepEqual(seen, [true, false, true]);
|
||||
});
|
||||
|
||||
test("selection AI overlay honors the visibility preference", () => {
|
||||
const overlayPosition = { left: 120, top: 80 };
|
||||
const addSelection = () => {};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
|
||||
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
|
||||
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
resolveTerminalTimestampGutterColor,
|
||||
resolveTerminalTimestampGutterWidth,
|
||||
} from './TerminalTimestampGutter';
|
||||
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -122,6 +124,108 @@ export function shouldShowLineTimestampToolbarToggle(
|
||||
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
|
||||
}
|
||||
|
||||
export function resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter,
|
||||
alternateScreenActive,
|
||||
gutterWidth,
|
||||
}: {
|
||||
showLineTimestampGutter: boolean;
|
||||
alternateScreenActive: boolean;
|
||||
gutterWidth: number;
|
||||
}): { enabled: boolean; width: number } {
|
||||
const enabled = showLineTimestampGutter && !alternateScreenActive;
|
||||
return { enabled, width: enabled ? gutterWidth : 0 };
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
|
||||
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
|
||||
|
||||
type TerminalAlternateScreenDisposable = { dispose: () => void };
|
||||
|
||||
type TerminalAlternateScreenWatchTarget = {
|
||||
buffer?: { active?: { type?: string } };
|
||||
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
|
||||
};
|
||||
|
||||
const readAlternateScreenActive = (
|
||||
term: TerminalAlternateScreenWatchTarget | null | undefined,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!term?.buffer?.active) return false;
|
||||
return isTerminalAlternateScreenActive(term as XTerm);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Alternate-screen programs intentionally skip timestamp recording. onRender
|
||||
* collapses and restores the gutter immediately. The timer attaches once xterm
|
||||
* exists and follows a replacement runtime after hibernate or reconnect.
|
||||
*/
|
||||
export function subscribeTerminalAlternateScreenChanges(
|
||||
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
|
||||
onChange: (alternateScreenActive: boolean) => void,
|
||||
): () => void {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
|
||||
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
|
||||
let lastActive: boolean | null = null;
|
||||
|
||||
const publish = (active: boolean) => {
|
||||
if (disposed || lastActive === active) return;
|
||||
lastActive = active;
|
||||
onChange(active);
|
||||
};
|
||||
|
||||
const detachRender = () => {
|
||||
renderDisposable?.dispose();
|
||||
renderDisposable = null;
|
||||
};
|
||||
|
||||
const watch = () => {
|
||||
if (disposed) return;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
const term = getTerm() ?? null;
|
||||
if (term !== attachedTerm) {
|
||||
detachRender();
|
||||
attachedTerm = term;
|
||||
if (term?.onRender) {
|
||||
renderDisposable = term.onRender(() => {
|
||||
if (disposed) return;
|
||||
const current = getTerm() ?? null;
|
||||
if (current !== term) {
|
||||
watch();
|
||||
return;
|
||||
}
|
||||
publish(readAlternateScreenActive(current));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
publish(readAlternateScreenActive(term));
|
||||
if (disposed) return;
|
||||
const delay = term?.onRender
|
||||
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
|
||||
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
|
||||
timer = setTimeout(watch, delay);
|
||||
};
|
||||
|
||||
watch();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
detachRender();
|
||||
attachedTerm = null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the tab/pane; only tear down the live transport. */
|
||||
export function shouldEnableStatusBarDisconnect(
|
||||
status: 'connecting' | 'connected' | 'disconnected' | undefined,
|
||||
@@ -486,6 +590,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
}, [hostTimestampsEnabled, timestampOverride]);
|
||||
const showLineTimestampGutter = lineTimestampsAvailable !== false
|
||||
&& (timestampOverride ?? hostTimestampsEnabled);
|
||||
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
|
||||
useEffect(() => subscribeTerminalAlternateScreenChanges(
|
||||
() => termRef.current,
|
||||
setAlternateScreenActive,
|
||||
), [sessionId, status, termRef]);
|
||||
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
|
||||
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
|
||||
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
|
||||
@@ -497,7 +606,12 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
|
||||
setLineTimestampGutterWidth((current) => (current === width ? current : width));
|
||||
}, []);
|
||||
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
|
||||
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
|
||||
showLineTimestampGutter,
|
||||
alternateScreenActive,
|
||||
gutterWidth: lineTimestampGutterWidth,
|
||||
});
|
||||
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
|
||||
const lineTimestampToggleLabel = showLineTimestampGutter
|
||||
? t("terminal.toolbar.timestampsDisable")
|
||||
: t("terminal.toolbar.timestampsEnable");
|
||||
@@ -1086,7 +1200,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
<TerminalTimestampGutter
|
||||
termRef={termRef}
|
||||
containerRef={containerRef}
|
||||
enabled={showLineTimestampGutter}
|
||||
enabled={lineTimestampGutterLayout.enabled}
|
||||
top={terminalContentTop}
|
||||
left={terminalBodyInset}
|
||||
bottom={terminalBottomInset}
|
||||
|
||||
Reference in new issue
Block a user