Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 |
@@ -301,6 +301,9 @@ const en: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
@@ -2092,6 +2095,11 @@ const en: Messages = {
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
|
||||
@@ -301,6 +301,9 @@ const ru: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
@@ -2124,6 +2127,11 @@ const ru: Messages = {
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
|
||||
|
||||
@@ -1445,6 +1445,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
@@ -2101,6 +2103,11 @@ const zhCN: Messages = {
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
@@ -506,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
|
||||
@@ -164,7 +164,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
|
||||
@@ -49,12 +49,14 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
@@ -1247,7 +1249,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
@@ -1269,6 +1273,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -2481,6 +2497,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -810,6 +810,12 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.keyboard.optionArrowWordJump")}
|
||||
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.accessibility")} />
|
||||
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
}
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
|
||||
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
|
||||
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
|
||||
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
|
||||
// flag was read from settings but only ever wired to the mouse alt-click
|
||||
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
|
||||
|
||||
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(true), {
|
||||
macOptionIsMeta: true,
|
||||
altClickMovesCursor: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(false), {
|
||||
macOptionIsMeta: false,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
});
|
||||
20
components/terminal/runtime/altKeyOptions.ts
Normal file
20
components/terminal/runtime/altKeyOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface TerminalAltKeyOptions {
|
||||
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
|
||||
macOptionIsMeta: boolean;
|
||||
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
|
||||
altClickMovesCursor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the user's "Use Option as Meta key" setting to xterm.js options.
|
||||
*
|
||||
* Kept in one place so terminal init (createXTermRuntime) and the live settings
|
||||
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
|
||||
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
|
||||
*/
|
||||
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
|
||||
return {
|
||||
macOptionIsMeta: altAsMeta,
|
||||
altClickMovesCursor: !altAsMeta,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
@@ -294,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
smoothScrollDuration,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
...terminalAltKeyOptions(altIsMeta),
|
||||
wordSeparator,
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
@@ -656,6 +658,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
|
||||
// #826). After kitty mode so apps using the kitty protocol keep their own
|
||||
// arrow encoding; read live so the toggle applies without reconnecting.
|
||||
const wordJumpSequence = optionArrowWordJumpSequence(
|
||||
e,
|
||||
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
|
||||
isMacPlatform(),
|
||||
);
|
||||
if (wordJumpSequence) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.onAutocompleteInput?.(wordJumpSequence);
|
||||
ctx.terminalBackend.writeToSession(id, wordJumpSequence);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
|
||||
}
|
||||
scrollToBottomAfterInput(wordJumpSequence);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
|
||||
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
|
||||
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
|
||||
// readline/zle does backward-word / forward-word out of the box (Termius-style).
|
||||
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
|
||||
|
||||
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
|
||||
});
|
||||
|
||||
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
|
||||
});
|
||||
|
||||
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
|
||||
});
|
||||
|
||||
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
|
||||
});
|
||||
|
||||
test("no Option held → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
|
||||
});
|
||||
|
||||
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
|
||||
});
|
||||
|
||||
test("non-arrow keys → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
|
||||
});
|
||||
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface OptionArrowKeyEvent {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Option+←/→ word-jump (discussion #826).
|
||||
*
|
||||
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
|
||||
* readline/zle does backward-word / forward-word without per-host bindkey setup.
|
||||
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
|
||||
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
|
||||
* default ^[[1;3D / ^[[1;3C is left untouched.
|
||||
*
|
||||
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
|
||||
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
|
||||
* devices (discussion #826 review).
|
||||
*/
|
||||
export function optionArrowWordJumpSequence(
|
||||
e: OptionArrowKeyEvent,
|
||||
enabled: boolean,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!enabled || !isMac) return null;
|
||||
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
|
||||
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
|
||||
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
|
||||
return null;
|
||||
}
|
||||
@@ -493,6 +493,7 @@ export interface TerminalSettings {
|
||||
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
@@ -692,6 +693,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
cursorBlink: true,
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
|
||||
@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// bash and csh/tcsh print a banner to the terminal right before exiting due to
|
||||
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
|
||||
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
|
||||
// code, no signal — so it is indistinguishable from a user-typed `exit` by
|
||||
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
|
||||
// reliable discriminator, letting the SSH bridge keep the tab open for
|
||||
// reconnect instead of auto-closing it (#1062, regression of #977).
|
||||
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
|
||||
|
||||
function looksLikeIdleAutoLogout(outputTail) {
|
||||
if (typeof outputTail !== "string" || !outputTail) return false;
|
||||
// The shell prints this banner on its own line as the very last thing before
|
||||
// it exits, so anchor on the final non-empty line rather than a loose
|
||||
// substring. Otherwise unrelated output that merely mentions "auto-logout"
|
||||
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
|
||||
// would be misclassified as a timeout and wrongly keep the tab open.
|
||||
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
|
||||
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
|
||||
if (!line) continue;
|
||||
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
@@ -399,6 +425,7 @@ module.exports = {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
looksLikeIdleAutoLogout,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
|
||||
// bash prints this immediately before a TMOUT auto-logout exit. The exit
|
||||
// itself is a clean shell exit (code 0, no signal), so the banner is the
|
||||
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
|
||||
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
|
||||
// and must still auto-close the tab.
|
||||
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
|
||||
// "auto-logout" scrolled past long ago; the user then ran more commands and
|
||||
// exited normally. Only the tail end is inspected, so this is not a timeout.
|
||||
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
|
||||
// Investigating TMOUT: the user greps the profile (output mentions
|
||||
// "auto-logout"), reads it, then exits on purpose. The banner is not the
|
||||
// final line, so the tab must still auto-close. Guards against matching an
|
||||
// unanchored substring anywhere in the recent output.
|
||||
const tail =
|
||||
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
|
||||
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
|
||||
"root@h:~# exit\r\nlogout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
|
||||
// The banner can share a line with the trailing prompt after ANSI/control
|
||||
// bytes are stripped (observed over real SSH); anchoring on the line end
|
||||
// must still match.
|
||||
const tail =
|
||||
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout(""), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(undefined), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(null), false);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ const {
|
||||
isPassphraseCancelledError,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { trackSessionIdlePrompt, looksLikeIdleAutoLogout } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const {
|
||||
buildAlgorithms,
|
||||
@@ -365,6 +365,8 @@ function resolveLangFromCharset(charset) {
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
*/
|
||||
@@ -1330,6 +1332,31 @@ async function startSSHSession(event, options) {
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
probeReceiveConflicts(names) {
|
||||
return probeReceiveConflicts(sessions.get(sessionId), names);
|
||||
},
|
||||
removeRemoteFiles(paths) {
|
||||
return removeRemoteFiles(sessions.get(sessionId), paths);
|
||||
},
|
||||
restoreRemoteModes(entries) {
|
||||
return restoreRemoteModes(sessions.get(sessionId), entries);
|
||||
},
|
||||
requestOverwriteDecision(filename) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
zmodemOverwritePending.delete(requestId);
|
||||
resolve({ action: "skip", applyToRest: false });
|
||||
}, 120000);
|
||||
zmodemOverwritePending.set(requestId, (payload) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ action: payload.action, applyToRest: !!payload.applyToRest });
|
||||
});
|
||||
safeSend(event.sender, "netcatty:zmodem:overwrite-request", {
|
||||
sessionId, requestId, filename,
|
||||
});
|
||||
});
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
@@ -1386,7 +1413,14 @@ async function startSSHSession(event, options) {
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
||||
// signal) — identical to a user-typed `exit` by code/signal —
|
||||
// so detect it via the banner the shell prints just before
|
||||
// exiting and report it as a timeout. That keeps the tab open
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(session?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
@@ -2012,24 +2046,58 @@ async function getSessionPwd(event, payload) {
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
find_child_shell() {
|
||||
mode=$2
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
|
||||
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
|
||||
if (index($3, "+") > 0) { print $1; found=1; exit }
|
||||
if (mode != "foreground" && pid == "") pid=$1
|
||||
# Find the interactive shell child of this exec channel's sshd ($PPID).
|
||||
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
||||
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
||||
# without the tty preference a concurrent probe's shell could be picked when
|
||||
# several exist under the same sshd (#1065 review). Falls back to any shell
|
||||
# child if none has a tty.
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && mode != "foreground" && pid != "") print pid }
|
||||
END { if (!found && any != "") print any }
|
||||
'
|
||||
}
|
||||
pid=$(find_child_shell "$PPID" any)
|
||||
while [ -n "$pid" ]; do
|
||||
child=$(find_child_shell "$pid" foreground)
|
||||
[ -n "$child" ] || break
|
||||
pid="$child"
|
||||
done
|
||||
if [ -n "$pid" ]; then
|
||||
# From the login shell, pick the DEEPEST foreground shell in its process
|
||||
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
||||
# in stat), i.e. the shell the user is actually typing in. Walking the whole
|
||||
# subtree (rather than only direct shell children) lets us follow through
|
||||
# non-shell foreground parents like su / sudo, so we read the cwd of the
|
||||
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
||||
# back to the login shell when no foreground shell is found.
|
||||
find_active_shell() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
||||
function isshell(c) { return c ~ /^-?(ba|z|fi|k|da|a)?sh$/ }
|
||||
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
||||
END {
|
||||
best=-1; bp="";
|
||||
for (i=1; i<=NR; i++) {
|
||||
p=ord[i];
|
||||
if (!isshell(cm[p])) continue;
|
||||
if (index(st[p], "+") == 0) continue;
|
||||
d=depth(p); if (d < 0) continue;
|
||||
if (d > best) { best=d; bp=p }
|
||||
}
|
||||
print (bp != "" ? bp : start)
|
||||
}
|
||||
'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
if [ -n "$login" ]; then
|
||||
pid=$(find_active_shell "$login")
|
||||
[ -n "$pid" ] || pid="$login"
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
|
||||
# /proc/<pid>/cwd is only readable for same-uid processes (ptrace perms), so
|
||||
# this unprivileged exec channel cannot read a su'd / sudo'd shell owned by
|
||||
# another user. Fall back to the same-uid login shell's cwd before giving up
|
||||
# to the home directory (#1065 review).
|
||||
if [ -z "$cwd" ] && [ "$pid" != "$login" ]; then
|
||||
cwd=$(readlink /proc/$login/cwd 2>/dev/null)
|
||||
fi
|
||||
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
||||
fi
|
||||
emit_home() {
|
||||
@@ -2077,6 +2145,103 @@ exit 1`;
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the directory the running `rz` writes to (its own cwd) and report
|
||||
// which of `names` already exist there. Returns { dir, existing } or null.
|
||||
function probeReceiveConflicts(session, names) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
||||
return resolve(null);
|
||||
}
|
||||
const timer = setTimeout(() => resolve(null), 5000);
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && any != "") print any }'
|
||||
}
|
||||
find_fg_leaf() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
||||
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
||||
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
||||
if(index(st[p],"+")==0) continue; d=depth(p); if(d<0) continue;
|
||||
if(d>best){best=d; bp=p} } print bp }'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
[ -n "$login" ] || exit 0
|
||||
leaf=$(find_fg_leaf "$login")
|
||||
[ -n "$leaf" ] || leaf="$login"
|
||||
dir=$(readlink /proc/$leaf/cwd 2>/dev/null)
|
||||
[ -n "$dir" ] || exit 0
|
||||
printf 'DIR\\t%s\\n' "$dir"
|
||||
cd "$dir" 2>/dev/null || exit 0
|
||||
for n in "$@"; do
|
||||
[ -e "$n" ] || continue
|
||||
m=$(stat -c %a -- "$n" 2>/dev/null || stat -f %Lp -- "$n" 2>/dev/null)
|
||||
printf 'EXIST\\t%s\\t%s\\n' "$n" "$m"
|
||||
done`;
|
||||
const argv = names.map((n) => quoteShellArg(n)).join(" ");
|
||||
const cmd = `exec sh -c ${quoteShellArg(script)} sh ${argv}`;
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(null); }
|
||||
let out = "";
|
||||
stream.on("data", (d) => { out += d.toString(); });
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
let dir = null; const existing = []; const modes = {};
|
||||
for (const line of out.split("\n")) {
|
||||
const [tag, val, mode] = line.split("\t");
|
||||
if (tag === "DIR") dir = val;
|
||||
else if (tag === "EXIST" && val) {
|
||||
existing.push(val);
|
||||
if (mode && /^[0-7]{3,4}$/.test(mode)) modes[val] = mode;
|
||||
}
|
||||
}
|
||||
resolve(dir ? { dir, existing, modes } : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// rm -f the given absolute remote paths (quoted; injection-safe).
|
||||
function removeRemoteFiles(session, paths) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return resolve();
|
||||
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
session.conn.exec(`exec sh -c 'rm -f -- "$@"' sh ${argv}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// chmod the given { path, mode } entries back to their captured permissions
|
||||
// (parameterized; injection-safe). Modes are validated octal before use.
|
||||
function restoreRemoteModes(session, entries) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(entries) || entries.length === 0) return resolve();
|
||||
const args = [];
|
||||
for (const e of entries) {
|
||||
if (!e || !e.path || !/^[0-7]{3,4}$/.test(String(e.mode))) continue;
|
||||
args.push(quoteShellArg(String(e.mode)));
|
||||
args.push(quoteShellArg(e.path));
|
||||
}
|
||||
if (args.length === 0) return resolve();
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const script = 'while [ "$#" -ge 2 ]; do chmod "$1" "$2" 2>/dev/null; shift 2; done';
|
||||
session.conn.exec(`exec sh -c ${quoteShellArg(script)} sh ${args.join(" ")}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on remote machine for path autocomplete.
|
||||
* Uses a separate exec channel — does not touch the interactive shell.
|
||||
@@ -2653,6 +2818,10 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
|
||||
const resolve = zmodemOverwritePending.get(payload?.requestId);
|
||||
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
|
||||
@@ -81,6 +81,7 @@ test("execCommand stops when an identity file passphrase prompt is cancelled", a
|
||||
handle(channel, handler) {
|
||||
this.handlers.set(channel, handler);
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const execHandler = ipcMain.handlers.get("netcatty:ssh:exec");
|
||||
|
||||
@@ -20,6 +20,58 @@ function getElectron() {
|
||||
return _electron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
|
||||
* `resolveDecision(name)` is awaited only for files in `existingList`, in input
|
||||
* order; `{ applyToRest: true }` reuses that action for the remaining conflicts.
|
||||
* Returns indices into the original `names` array so callers preserve per-file
|
||||
* identity even when two files share a basename.
|
||||
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
|
||||
*/
|
||||
async function buildUploadPlan(names, existingList, resolveDecision) {
|
||||
const existing = new Set(existingList);
|
||||
const offerIndices = [];
|
||||
const removeIndices = [];
|
||||
let bulkAction = null;
|
||||
for (let idx = 0; idx < names.length; idx++) {
|
||||
const name = names[idx];
|
||||
if (!existing.has(name)) { offerIndices.push(idx); continue; }
|
||||
let action = bulkAction;
|
||||
if (!action) {
|
||||
const decision = (await resolveDecision(name)) || { action: "skip" };
|
||||
action = decision.action;
|
||||
if (decision.applyToRest && action !== "cancel") bulkAction = action;
|
||||
}
|
||||
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
|
||||
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
|
||||
// 'skip' → omit from both
|
||||
}
|
||||
return { offerIndices, removeIndices, aborted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which overwritten files need their original mode restored after rz
|
||||
* re-creates them. rz writes new files with the remote umask, dropping the
|
||||
* prior permission bits (issue #1079). Pure: returns absolute `{ path, mode }`
|
||||
* entries for the overwritten files, skipping any whose mode wasn't captured
|
||||
* and de-duplicating shared basenames.
|
||||
*/
|
||||
function buildModeRestores(dir, names, removeIndices, modes) {
|
||||
const base = String(dir).replace(/\/+$/, "");
|
||||
const seen = new Set();
|
||||
const restores = [];
|
||||
for (const i of removeIndices) {
|
||||
const name = names[i];
|
||||
const mode = modes && modes[name];
|
||||
if (!mode) continue;
|
||||
const target = `${base}/${name}`;
|
||||
if (seen.has(target)) continue;
|
||||
seen.add(target);
|
||||
restores.push({ path: target, mode });
|
||||
}
|
||||
return restores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||||
*
|
||||
@@ -524,10 +576,45 @@ async function handleUpload(zsession, opts) {
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
const allNames = filePaths.map((fp) => path.basename(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||||
let probeDir = null;
|
||||
let probeModes = null;
|
||||
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
try {
|
||||
const probe = await opts.probeReceiveConflicts(allNames);
|
||||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||||
probeDir = probe.dir;
|
||||
probeModes = probe.modes || {};
|
||||
plan = await buildUploadPlan(allNames, probe.existing, opts.requestOverwriteDecision);
|
||||
if (plan.aborted) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
if (plan.removeIndices.length && opts.removeRemoteFiles) {
|
||||
const base = probe.dir.replace(/\/+$/, "");
|
||||
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
|
||||
try {
|
||||
await opts.removeRemoteFiles(targets);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
|
||||
|
||||
for (let i = 0; i < offers.length; i++) {
|
||||
const { filePath, stat, name } = offers[i];
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
@@ -535,18 +622,18 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
files_remaining: offers.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
@@ -579,7 +666,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
@@ -597,7 +684,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
@@ -608,6 +695,20 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
|
||||
await withTimeout(zsession.close(), 120000);
|
||||
|
||||
// rz re-creates overwritten files with the remote umask, dropping their
|
||||
// original permission bits. Now that everything is on disk, restore them
|
||||
// to the modes captured before the rm (issue #1079).
|
||||
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
|
||||
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
|
||||
if (restores.length) {
|
||||
try {
|
||||
await opts.restoreRemoteModes(restores);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,4 +892,4 @@ function safeSend(contents, channel, data) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createZmodemSentry };
|
||||
module.exports = { createZmodemSentry, buildUploadPlan, buildModeRestores };
|
||||
|
||||
74
electron/bridges/zmodemHelper.test.cjs
Normal file
74
electron/bridges/zmodemHelper.test.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
|
||||
|
||||
const never = () => { throw new Error("resolver should not be called"); };
|
||||
|
||||
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("overwrite a conflict: index both removed and offered", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
test("skip a conflict: index omitted from offer and remove", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("cancel aborts the whole transfer", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
|
||||
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
|
||||
});
|
||||
|
||||
test("applyToRest reuses the action and stops prompting", async () => {
|
||||
let calls = 0;
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
|
||||
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
|
||||
});
|
||||
|
||||
test("only conflicting files invoke the resolver; order preserved", async () => {
|
||||
const seen = [];
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
|
||||
async (n) => { seen.push(n); return { action: "skip" }; });
|
||||
assert.deepEqual(seen, ["b"]);
|
||||
assert.deepEqual(plan.offerIndices, [0, 2]);
|
||||
});
|
||||
|
||||
test("duplicate basenames keep independent per-file decisions", async () => {
|
||||
// Two different local files share a basename; skip the first, overwrite the second.
|
||||
const actions = ["skip", "overwrite"];
|
||||
let i = 0;
|
||||
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
|
||||
async () => ({ action: actions[i++] }));
|
||||
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
// Issue #1079: overwriting (rm + rz re-create) drops the original permission
|
||||
// bits. buildModeRestores resolves which overwritten files to chmod back.
|
||||
|
||||
test("buildModeRestores maps overwritten files to their captured modes", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/home/u", ["a.sh", "b.txt"], [0], { "a.sh": "755" }),
|
||||
[{ path: "/home/u/a.sh", mode: "755" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores skips files whose mode was not captured", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv", ["a", "b"], [0, 1], { a: "644" }),
|
||||
[{ path: "/srv/a", mode: "644" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores strips trailing slashes and dedupes duplicate basenames", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv//", ["x", "x"], [0, 1], { x: "600" }),
|
||||
[{ path: "/srv/x", mode: "600" }],
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const zmodemListeners = new Map();
|
||||
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const telnetAutoLoginCompleteListeners = new Map();
|
||||
@@ -137,6 +138,10 @@ ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
|
||||
const set = zmodemOverwriteListeners.get(payload.sessionId);
|
||||
if (set) set.forEach((cb) => cb(payload));
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -185,6 +190,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
telnetAutoLoginCompleteListeners.delete(payload.sessionId);
|
||||
telnetAutoLoginCancelledListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
zmodemOverwriteListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -682,6 +688,14 @@ const api = {
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
},
|
||||
onZmodemOverwriteRequest: (sessionId, cb) => {
|
||||
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());
|
||||
zmodemOverwriteListeners.get(sessionId).add(cb);
|
||||
return () => zmodemOverwriteListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
respondZmodemOverwrite: (payload) => {
|
||||
ipcRenderer.send("netcatty:zmodem:overwrite-response", payload);
|
||||
},
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
|
||||
9
global.d.ts
vendored
9
global.d.ts
vendored
@@ -341,6 +341,15 @@ declare global {
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onZmodemOverwriteRequest?(
|
||||
sessionId: string,
|
||||
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
|
||||
): () => void;
|
||||
respondZmodemOverwrite?(payload: {
|
||||
requestId: string;
|
||||
action: "overwrite" | "skip" | "cancel";
|
||||
applyToRest: boolean;
|
||||
}): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
|
||||
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Disable @xterm/addon-webgl's cross-terminal texture-atlas sharing.
|
||||
*
|
||||
* xterm's WebGL addon shares ONE TextureAtlas across terminal instances whose
|
||||
* config (font / size / theme / device-pixel-ratio) is equal — see
|
||||
* `acquireTextureAtlas`, which does `if (configEquals) { ownedBy.push; return
|
||||
* atlas }`. In a split workspace two panes then share an atlas, so clearing or
|
||||
* rebuilding it for one pane (which netcatty does on resize / DPR change / font
|
||||
* change / tab show to recover from glyph corruption) corrupts the OTHER pane's
|
||||
* rendering — the persistent "花屏 / garbled" report in issue #1063, most
|
||||
* visible in split view where both panes stay on screen.
|
||||
*
|
||||
* Fix: give every terminal its own atlas by removing the "reuse a matching
|
||||
* atlas" loop, so each terminal falls through to creating its own. The published
|
||||
* package is minified, so we string-replace the exact loop in both the CJS and
|
||||
* ESM builds. This runs from `postinstall` (after patch-package).
|
||||
*
|
||||
* Idempotent. If the upstream code changes (e.g. an @xterm/addon-webgl upgrade)
|
||||
* the loop won't be found; we warn loudly but do not fail the install, and the
|
||||
* strings below must then be refreshed for the new version.
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "/*netcatty:#1063 atlas-isolation*/";
|
||||
|
||||
// Exact (minified) "reuse a shared atlas" loop, per @xterm/addon-webgl@0.19.0.
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
|
||||
loop: "for(let h=0;h<le.length;h++){let f=le[h];if(Mi(f.config,u))return f.ownedBy.push(i),f.atlas}",
|
||||
},
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
|
||||
loop: "for(let t=0;t<r.length;t++){const i=r[t];if((0,n.configEquals)(i.config,d))return i.ownedBy.push(e),i.atlas}",
|
||||
},
|
||||
];
|
||||
|
||||
let patched = 0;
|
||||
let already = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const { file, loop } of TARGETS) {
|
||||
const abs = path.resolve(process.cwd(), file);
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
console.warn(`[patch-xterm-webgl-atlas] skip (not found): ${file}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
if (src.includes(MARKER)) {
|
||||
already++;
|
||||
continue;
|
||||
}
|
||||
if (!src.includes(loop)) {
|
||||
console.warn(
|
||||
`[patch-xterm-webgl-atlas] WARNING: atlas-sharing loop not found in ${file}. ` +
|
||||
"@xterm/addon-webgl likely changed — split-view WebGL may garble again (#1063). " +
|
||||
"Refresh the minified target strings in scripts/patch-xterm-webgl-atlas.cjs.",
|
||||
);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(abs, src.replace(loop, MARKER));
|
||||
patched++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[patch-xterm-webgl-atlas] atlas isolation: patched=${patched} already=${already} missing=${missing}`,
|
||||
);
|
||||
@@ -44,7 +44,6 @@ export default defineConfig(() => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Vendor chunks - rarely change, can be cached aggressively
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-radix': [
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-context-menu',
|
||||
|
||||
Reference in New Issue
Block a user