mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 06:22:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1367a67680 |
No files matched your search
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('enter reconnect stays off until the session allows it and does not queue', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const requests: string[] = [];
|
||||
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
registry.register('session-1', () => requests.push('session-1'));
|
||||
assert.deepEqual(requests, []);
|
||||
|
||||
registry.setEnterReconnectEnabled('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), true);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', true);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
|
||||
registry.setActive('session-1', false);
|
||||
registry.setEnterReconnectEnabled('session-1', false);
|
||||
assert.equal(registry.requestEnterReconnect('session-1'), false);
|
||||
assert.deepEqual(requests, ['session-1']);
|
||||
});
|
||||
|
||||
test('unregistering the current handler clears active reconnect state', () => {
|
||||
const registry = createTerminalReconnectRegistry();
|
||||
const unregister = registry.register('session-1', () => {});
|
||||
|
||||
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
|
||||
const handlers = new Map<string, TerminalReconnectHandler>();
|
||||
const pendingRequests = new Set<string>();
|
||||
const activeSessionIds = new Set<string>();
|
||||
const enterReconnectEnabledIds = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emit = () => {
|
||||
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return { register, request, setActive, isActive, subscribe };
|
||||
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
|
||||
// request(); this path stays off during auth, host-key, and blocking overlays.
|
||||
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
|
||||
if (enabled) enterReconnectEnabledIds.add(sessionId);
|
||||
else enterReconnectEnabledIds.delete(sessionId);
|
||||
};
|
||||
|
||||
const requestEnterReconnect = (sessionId: string): boolean => {
|
||||
if (!enterReconnectEnabledIds.has(sessionId)) return false;
|
||||
return request(sessionId);
|
||||
};
|
||||
|
||||
return {
|
||||
register,
|
||||
request,
|
||||
requestEnterReconnect,
|
||||
setEnterReconnectEnabled,
|
||||
setActive,
|
||||
isActive,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
|
||||
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
|
||||
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
const sessionId = 'compose-enter-reconnect';
|
||||
const reconnects: string[] = [];
|
||||
const unregister = terminalReconnectRegistry.register(sessionId, () => {
|
||||
reconnects.push(sessionId);
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const createTextarea = () => ({
|
||||
value: '', selectionStart: 0, selectionEnd: 0,
|
||||
focus() {},
|
||||
setSelectionRange() {},
|
||||
});
|
||||
let textarea = createTextarea();
|
||||
const sent: string[] = [];
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<TooltipProvider>
|
||||
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
|
||||
</TooltipProvider>,
|
||||
{ createNodeMock: (el) => {
|
||||
if (el.type !== 'textarea') return null;
|
||||
textarea = createTextarea();
|
||||
return textarea;
|
||||
} },
|
||||
);
|
||||
});
|
||||
const key = (name: string, extra: Record<string, unknown> = {}) => {
|
||||
let prevented = false;
|
||||
root.root.findByType('textarea').props.onKeyDown({
|
||||
key: name,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
nativeEvent: {},
|
||||
currentTarget: textarea,
|
||||
preventDefault() { prevented = true; },
|
||||
...extra,
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'echo hi';
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.equal(textarea.value, '');
|
||||
|
||||
textarea.value = 'keep me';
|
||||
assert.equal(key('Enter', { shiftKey: true }), false);
|
||||
assert.equal(textarea.value, 'keep me');
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
textarea.value = '';
|
||||
assert.equal(key('Enter', { ctrlKey: true }), true);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
assert.equal(key('Enter'), true);
|
||||
assert.deepEqual(reconnects, [sessionId]);
|
||||
assert.deepEqual(sent, ['echo hi']);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
|
||||
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
|
||||
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
// Plain Enter on an empty draft reconnects a disconnected session.
|
||||
// Modified Enter and a non-empty draft keep the existing send path (#3767).
|
||||
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
|
||||
if (
|
||||
plainEnter
|
||||
&& !e.currentTarget.value
|
||||
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, navigate, onClose]);
|
||||
}, [handleSend, navigate, onClose, sessionId]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
|
||||
key: "Enter",
|
||||
status: "disconnected",
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
key: "Enter",
|
||||
status: "disconnected" as const,
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
|
||||
// Open search must not globally suppress Enter reconnect / the hint (#2546).
|
||||
// Open search and an open compose bar must not globally suppress Enter
|
||||
// reconnect / the hint (#2546 / #3767).
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { classifyDistroId } from '../../domain/host';
|
||||
import type { HostInfoBarTitleMode } from '../../domain/models';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { OSC7_SETUP_TARGETS } from './osc7Setup';
|
||||
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key,
|
||||
status,
|
||||
hasRetryHandler,
|
||||
isComposeBarOpen,
|
||||
needsAuth,
|
||||
needsHostKeyVerification,
|
||||
hasBlockingOverlay,
|
||||
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
key: string;
|
||||
status?: string;
|
||||
hasRetryHandler: boolean;
|
||||
isComposeBarOpen: boolean;
|
||||
needsAuth: boolean;
|
||||
needsHostKeyVerification: boolean;
|
||||
hasBlockingOverlay: boolean;
|
||||
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
|
||||
shiftKey?: boolean;
|
||||
isComposing?: boolean;
|
||||
}): boolean {
|
||||
// Search-bar open state is intentionally not a global gate. While disconnected,
|
||||
// find-next is less useful than reconnect; the capture handler still refuses
|
||||
// Enter only when a real interactive control outside xterm owns the event
|
||||
// (compose/auth/buttons). The terminal search input is allow-listed so an open
|
||||
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
|
||||
// Search and compose bars are not global gates. While disconnected, plain
|
||||
// Enter still reconnects from the terminal and the disconnected dialog.
|
||||
// Capture still skips real controls outside xterm (compose textarea, auth,
|
||||
// buttons). Search input is allow-listed so an open search bar cannot hide
|
||||
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
|
||||
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
|
||||
return key === "Enter"
|
||||
&& status === "disconnected"
|
||||
&& hasRetryHandler
|
||||
&& !isComposeBarOpen
|
||||
&& !needsAuth
|
||||
&& !needsHostKeyVerification
|
||||
&& !hasBlockingOverlay
|
||||
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
key: "Enter",
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
isReconnectActive,
|
||||
});
|
||||
// Publish Enter-reconnect so an empty compose draft can reconnect this
|
||||
// session, including the workspace compose bar (#3767).
|
||||
useEffect(() => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
|
||||
return () => {
|
||||
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
|
||||
};
|
||||
}, [sessionId, showEnterReconnectHint]);
|
||||
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!shouldReconnectTerminalOnEnterKey({
|
||||
key: event.key,
|
||||
status,
|
||||
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
|
||||
isComposeBarOpen,
|
||||
needsAuth: Boolean(auth.needsAuth),
|
||||
needsHostKeyVerification: Boolean(needsHostKeyVerification),
|
||||
hasBlockingOverlay: hasBlockingReconnectOverlay,
|
||||
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
auth.needsAuth,
|
||||
handleRetry,
|
||||
hasBlockingReconnectOverlay,
|
||||
isComposeBarOpen,
|
||||
isReconnectActive,
|
||||
needsHostKeyVerification,
|
||||
status,
|
||||
|
||||
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
|
||||
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
|
||||
});
|
||||
|
||||
test("open compose bar does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
|
||||
const gateBody = viewSource.slice(gateIndex, bodyEnd);
|
||||
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
|
||||
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
|
||||
assert.match(
|
||||
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
|
||||
/requestEnterReconnect\(sessionId\)/,
|
||||
);
|
||||
assert.match(
|
||||
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
|
||||
/data-section='terminal-compose-bar'/,
|
||||
);
|
||||
});
|
||||
|
||||
test("open terminal search does not globally gate enter reconnect", () => {
|
||||
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
|
||||
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
|
||||
test("terminal view receives the effective compose bar state", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
|
||||
const viewIndex = source.indexOf("<TerminalView ctx={{");
|
||||
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
|
||||
assert.notEqual(passEffectiveIndex, -1);
|
||||
assert.ok(
|
||||
effectiveDefinitionIndex < passEffectiveIndex,
|
||||
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
|
||||
"TerminalView must receive the visible workspace compose state",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
|
||||
// Sink already focused.
|
||||
return active !== dialogNode;
|
||||
}
|
||||
// An open compose draft owns Enter: empty reconnects from the bar, and a
|
||||
// non-empty draft must keep send behavior (#3767). Do not park focus here.
|
||||
if (active.closest("[data-section='terminal-compose-bar']")) {
|
||||
return false;
|
||||
}
|
||||
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
|
||||
if (terminalRoot?.contains(active)) {
|
||||
return true;
|
||||
|
||||
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
id: string;
|
||||
parent?: FakeNode | null;
|
||||
interactive?: boolean;
|
||||
composeBar?: boolean;
|
||||
children?: FakeNode[];
|
||||
},
|
||||
) {
|
||||
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
return false;
|
||||
}
|
||||
closest(selector: string) {
|
||||
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
|
||||
if (selector.includes("button") && this.opts.interactive) return this;
|
||||
return null;
|
||||
}
|
||||
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Compose-bar focus stays put so an empty draft can reconnect and a
|
||||
// non-empty draft is not stolen by the disconnected dialog (#3767).
|
||||
const composeInput = new FakeNode({ id: "compose", composeBar: true });
|
||||
const composeDialog = new FakeNode({ id: "compose-dialog" });
|
||||
const composeSession = new FakeNode({
|
||||
id: "compose-session",
|
||||
children: [composeInput, composeDialog],
|
||||
});
|
||||
assert.equal(
|
||||
shouldClaimDisconnectedDialogFocus({
|
||||
activeElement: composeInput as unknown as Element,
|
||||
dialogNode: composeDialog as unknown as HTMLElement,
|
||||
sessionRoot: composeSession as unknown as Element,
|
||||
documentBody: body as unknown as Element,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
restoreTerminalFocusFromDisconnectedDialog({
|
||||
|
||||
Reference in new issue
Block a user