Compare commits

...

20 Commits

Author SHA1 Message Date
陈大猫
86a815ad46 [codex] Optimize terminal tab switching (#1003)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Optimize terminal tab switching

* Reduce themed tab switch repaint work
2026-05-18 22:19:54 +08:00
陈大猫
cb4fb091aa [codex] Fix browser loading of shared rule files (#1002)
* Fix local shell browser import

* Fix command blocklist browser import
2026-05-18 21:05:33 +08:00
陈大猫
b30696c98b Clean up dead code and duplicated helpers (#1001) 2026-05-18 20:00:10 +08:00
bincxz
6b8f05c65a Merge branch 'codex/fix-russian-settings-sync-icon' 2026-05-18 19:23:44 +08:00
bincxz
64dd3a4a2f Fix settings sidebar icon clipping 2026-05-18 19:23:36 +08:00
yuzifu
88732040aa fix(terminal): separate prompt after unterminated command output (#987)
* fix(terminal): separate prompt after unterminated command output
Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated.

Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion.

* fix review issue

* Fix prompt cache initialization

* Serialize terminal output writes for prompt breaks

* Keep terminal status lines ordered with output

* Fix prompt arming without command callback

* Keep prompt display breaks out of session logs

* Avoid prompt breaks for output suffix matches

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 18:45:41 +08:00
ウィール スペース
b9f3bfa8bb Add i18n russian (#991)
* add i18n russian

* Added the Russian translation

* Complete Russian SFTP transfer translations

* Add Russian reconnect menu translation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 16:55:02 +08:00
陈大猫
b7ec3c12f7 Handle ConPTY controls in Mosh password prompts (#1000) 2026-05-18 15:44:58 +08:00
DeepFal
d20a18b862 Fix AI code block rendering fallback (#983) 2026-05-18 13:19:30 +08:00
陈大猫
ff6b4a4625 Broadcast pasted terminal input (#927) (#996)
* Broadcast user paste to terminals

* Use workspace session id for context paste broadcast

* Consume paste broadcast suppression before toggle check
2026-05-18 11:53:14 +08:00
陈大猫
5a94b4cf39 Preserve Unicode session log names (#988) (#998)
* Preserve Unicode session log names

* Harden Windows session log name handling
2026-05-18 11:42:43 +08:00
陈大猫
3963cd4af9 Fix remote path completion cwd (#993) 2026-05-18 11:32:04 +08:00
陈大猫
5b2a048917 Add transfer target path actions (#997) 2026-05-18 11:31:50 +08:00
陈大猫
2414cb00e4 Keep terminal tab after remote exit (#994) 2026-05-18 11:31:28 +08:00
陈大猫
03f980e939 Add reconnect terminal context action (#995) 2026-05-18 11:30:27 +08:00
Bet4
ac819fd4fd feat(workspace): add focus sidebar drag reorder (#992) 2026-05-18 01:26:14 +08:00
yuzifu
fb9400a5fb fix #984: After running the clear command, the inline session log will be cleared (#990)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-05-16 20:44:05 +08:00
陈大猫
7da983a56c ci: auto-bump Homebrew tap on stable release tags (#938) (#976)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.

Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
  arm64 + x64 DMGs already downloaded by the release job, sed-patches the
  Cask file in the tap repo, sanity-checks the result parses as Ruby, and
  pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
  stable-tag gate, downloads the macOS artifact bundle, then runs the
  bump script with HOMEBREW_TAP_TOKEN.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:01:26 +08:00
陈大猫
344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00
陈大猫
86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
The host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:

1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
   landed in localStorage without a `fingerprint` field. The verifier then
   re-derived the fingerprint from the stored `publicKey` blob on every
   connect — a brittle path that produced a different value than ssh2 if
   anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
   keyType → changed" heuristic. Any imported record whose keyType failed
   to parse would be promoted to a rotation warning the first time the
   server presented a real algorithm, even though the user had never
   actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
   record plus a live ssh-ed25519 handshake) was being reported as
   `changed` instead of `unknown`, even though we had no comparable
   record for the algorithm the server presented.

Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.

Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
  `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
  legacy records on hydration. Pure-JS SHA-256 keeps the migration
  synchronous so it can run inline in `useVaultState` without async
  plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
  and on cross-window storage events. When anything changes on hydration
  the migrated list is written back to localStorage so the next launch
  starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
  time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
  fingerprint-first, then strict (host, port, keyType) match for the
  changed branch, then fall through to `unknown`. Two existing tests
  that locked in the loose heuristic are updated to assert the new
  (safer) behavior, and a new test covers the multi-algorithm
  first-encounter case.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:36 +08:00
127 changed files with 8192 additions and 1714 deletions

89
.github/scripts/bump-homebrew-cask.sh vendored Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."

View File

@@ -604,3 +604,33 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh

26
App.tsx
View File

@@ -177,12 +177,22 @@ const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
useEffect(() => {
if (shouldMount) return;
// Warm up the terminal layer shortly after first paint to reduce latency when opening a session.
const id = window.setTimeout(() => setShouldMount(true), 1200);
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
if (!shouldMount) return null;
const shouldRender = shouldMount || isVisible;
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
@@ -346,6 +356,7 @@ function App({ settings }: { settings: SettingsState }) {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
@@ -1715,6 +1726,10 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
updateHosts(hosts.map((h) => (h.id === host.id ? host : h)));
}, [hosts, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -2011,7 +2026,7 @@ function App({ settings }: { settings: SettingsState }) {
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
sessionCount={sessions.length}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
@@ -2099,7 +2114,7 @@ function App({ settings }: { settings: SettingsState }) {
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
@@ -2114,6 +2129,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}

View File

@@ -323,6 +323,9 @@ const en: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
'settings.terminal.behavior.forcePromptNewLine.desc':
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
@@ -819,6 +822,11 @@ const en: Messages = {
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
@@ -1323,6 +1331,7 @@ const en: Messages = {
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',

File diff suppressed because it is too large Load Diff

View File

@@ -586,6 +586,11 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
@@ -900,6 +905,7 @@ const zhCN: Messages = {
'terminal.menu.paste': '粘贴',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
@@ -1459,6 +1465,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',

View File

@@ -1,11 +1,13 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
};

View File

@@ -1,4 +1,4 @@
import { useCallback,useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};

View File

@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useEditorDirty = (id: EditorTabId): boolean => {
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useAnyEditorDirty = (): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
[],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};

View File

@@ -0,0 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});
test("backend timeout events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
{ kind: "markDisconnected" },
);
});

View File

@@ -0,0 +1,17 @@
export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
};
export type TerminalSessionExitIntent =
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
_evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
// Explicit user closes bypass this policy and call the close-session path directly.
return { kind: "markDisconnected" };
}

View File

@@ -0,0 +1,23 @@
import type { SftpBookmark } from "../../../domain/models";
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
export function getSftpBookmarkLabel(path: string): string {
const trimmed = path.trim();
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
}
export function createSftpBookmark(
path: string,
options: { global?: boolean; idPrefix?: string } = {},
): SftpBookmark {
const global = options.global === true;
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
return {
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label: getSftpBookmarkLabel(path),
...(global ? { global: true } : {}),
};
}

View File

@@ -0,0 +1,45 @@
import type { SftpBookmark } from "../../../domain/models";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
export function subscribeGlobalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getGlobalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateGlobalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setGlobalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
}
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalSftpBookmarks();
}
});
}

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isConcreteTransferTargetPath } from "./utils";
test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
});

View File

@@ -1,4 +1,4 @@
import { SftpFileEntry } from "../../../domain/models";
import { SftpFileEntry, TransferTask } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
return result;
};
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
const targetPath = task.targetPath.trim();
return targetPath.length > 0 && targetPath !== "(temp)";
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";

View File

@@ -19,11 +19,11 @@ import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '..
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import {
@@ -156,21 +156,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return {
hosts: config.hosts,
keys: config.keys,
@@ -179,7 +164,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
}, [

View File

@@ -1,41 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;
prevTab: () => void;
closeTab: () => void;
newTab: () => void;
// Navigation
openHosts: () => void;
openSftp: () => void;
quickSwitch: () => void;
newWorkspace: () => void;
commandPalette: () => void;
portForwarding: () => void;
snippets: () => void;
// Terminal actions (handled per-terminal)
copy: () => void;
paste: () => void;
selectAll: () => void;
clearBuffer: () => void;
searchTerminal: () => void;
// Workspace/split actions
splitHorizontal: () => void;
splitVertical: () => void;
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
// App features
broadcast: () => void;
openLocal: () => void;
openSettings: () => void;
}
// Check if keyboard event matches our app-level shortcuts
// Returns the matched binding action or null
export const checkAppShortcut = (
@@ -87,163 +51,3 @@ export const getTerminalPassthroughActions = (): Set<string> => {
'searchTerminal',
]);
};
interface UseGlobalHotkeysOptions {
hotkeyScheme: 'disabled' | 'mac' | 'pc';
keyBindings: KeyBinding[];
actions: Partial<HotkeyActions>;
orderedTabs: string[];
sessions: { id: string }[];
workspaces: { id: string }[];
isSettingsOpen?: boolean;
}
export const useGlobalHotkeys = ({
hotkeyScheme,
keyBindings,
actions,
orderedTabs,
sessions,
workspaces,
isSettingsOpen = false,
}: UseGlobalHotkeysOptions) => {
const actionsRef = useRef(actions);
actionsRef.current = actions;
const orderedTabsRef = useRef(orderedTabs);
orderedTabsRef.current = orderedTabs;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (hotkeyScheme === 'disabled') return;
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
const isMac = hotkeyScheme === 'mac';
const appLevelActions = getAppLevelActions();
// Check if this is an app-level shortcut
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return;
const { action, binding: _binding } = matched;
// Only handle app-level actions here
// Terminal-level actions are handled by the terminal itself
if (!appLevelActions.has(action)) return;
e.preventDefault();
e.stopPropagation();
const currentActions = actionsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
currentActions.switchToTab?.(num);
}
break;
}
case 'nextTab':
currentActions.nextTab?.();
break;
case 'prevTab':
currentActions.prevTab?.();
break;
case 'closeTab':
currentActions.closeTab?.();
break;
case 'newTab':
currentActions.newTab?.();
break;
case 'openHosts':
currentActions.openHosts?.();
break;
case 'openSftp':
currentActions.openSftp?.();
break;
case 'openLocal':
currentActions.openLocal?.();
break;
case 'quickSwitch':
currentActions.quickSwitch?.();
break;
case 'newWorkspace':
currentActions.newWorkspace?.();
break;
case 'commandPalette':
currentActions.commandPalette?.();
break;
case 'portForwarding':
currentActions.portForwarding?.();
break;
case 'snippets':
currentActions.snippets?.();
break;
case 'splitHorizontal':
currentActions.splitHorizontal?.();
break;
case 'splitVertical':
currentActions.splitVertical?.();
break;
case 'moveFocus': {
// Determine direction from arrow key
const key = e.key;
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
break;
}
case 'broadcast':
currentActions.broadcast?.();
break;
case 'openSettings':
currentActions.openSettings?.();
break;
}
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
useEffect(() => {
// Use capture phase to intercept before xterm
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [handleGlobalKeyDown]);
};
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
// Returns false to let xterm handle the key, true to prevent xterm from handling
export const createXtermKeyHandler = (
keyBindings: KeyBinding[],
isMac: boolean,
onTerminalAction?: (action: string, e: KeyboardEvent) => void
) => {
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
return (e: KeyboardEvent): boolean => {
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return true; // Let xterm handle it
const { action } = matched;
// App-level actions: prevent xterm from handling, let global handler take over
if (appLevelActions.has(action)) {
return false; // Don't let xterm handle, will bubble to global handler
}
// Terminal-level actions: handle here and prevent default
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
onTerminalAction?.(action, e);
return false;
}
return true; // Let xterm handle other keys
};
};

View File

@@ -9,6 +9,7 @@ FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -759,6 +760,27 @@ export const useSessionState = () => {
}));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
@@ -1049,6 +1071,7 @@ export const useSessionState = () => {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,

View File

@@ -154,6 +154,12 @@ export const useSftpBackend = () => {
return await netcattyBridge.get()?.listDrives?.() ?? [];
}, []);
const openPath = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.openPath) throw new Error("openPath unavailable");
return bridge.openPath(path);
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
@@ -273,6 +279,7 @@ export const useSftpBackend = () => {
statLocal,
getHomeDir,
listDrives,
openPath,
startStreamTransfer,
cancelTransfer,

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
ConnectionLog,
GroupConfig,
@@ -505,11 +506,22 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
// `keyType` fields the verifier compares against; backfill them now
// so the next SSH connect can match without falling into the brittle
// re-derivation path that caused the repeated "fingerprint changed"
// warnings in #972.
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
}
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
@@ -638,7 +650,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(next);
setKnownHosts(normalizeKnownHosts(next));
return;
}

View File

@@ -18,7 +18,12 @@ import type {
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
@@ -26,7 +31,7 @@ import {
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -67,6 +72,7 @@ import {
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -94,19 +100,7 @@ export interface SyncableVaultData {
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
@@ -118,24 +112,39 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
export function sanitizePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
if (!rules) return rules;
return rules.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
export function getEffectivePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
let effectiveRules = rules;
if (!effectiveRules || effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
if (Array.isArray(stored) && stored.length > 0) {
effectiveRules = stored;
}
}
return sanitizePortForwardingRulesForSync(effectiveRules);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
@@ -160,7 +169,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard', 'showServerStats',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
@@ -550,7 +559,7 @@ export function buildSyncPayload(
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
groupConfigs: vault.groupConfigs,
portForwardingRules,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
@@ -611,7 +620,7 @@ function applyPayload(
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
});

View File

@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,

View File

@@ -22,6 +22,7 @@ import React, {
import { useI18n } from "../application/i18n/I18nProvider";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
import { fingerprintFromPublicKey } from "../domain/knownHosts";
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -80,12 +81,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname = "(hashed)";
}
const fullPublicKey = `${keyType} ${publicKey}`;
// Compute the fingerprint up front so the SSH host verifier can match
// against this record directly instead of re-deriving on every connect —
// the re-derivation path is where the false "fingerprint changed"
// warnings in #972 originated.
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
parsed.push({
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname,
port,
keyType,
publicKey: `${keyType} ${publicKey}`,
publicKey: fullPublicKey,
fingerprint: fingerprint || undefined,
discoveredAt: Date.now(),
});
} catch {

View File

@@ -12,6 +12,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
@@ -50,6 +51,11 @@ type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
@@ -128,13 +134,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
[portForwardingRules],
);
@@ -213,51 +213,59 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
<TabsTrigger
value="application"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<AppWindow size={14} /> {t("settings.tab.application")}
<AppWindow size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.application")}</span>
</TabsTrigger>
<TabsTrigger
value="appearance"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Palette size={14} /> {t("settings.tab.appearance")}
<Palette size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.appearance")}</span>
</TabsTrigger>
<TabsTrigger
value="terminal"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<TerminalSquare size={14} /> {t("settings.tab.terminal")}
<TerminalSquare size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.terminal")}</span>
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
<Keyboard size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.shortcuts")}</span>
</TabsTrigger>
<TabsTrigger
value="file-associations"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
<FileType size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.sftpFileAssociations")}</span>
</TabsTrigger>
<TabsTrigger
value="ai"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Sparkles size={14} /> AI
<Sparkles size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>AI</span>
</TabsTrigger>
<TabsTrigger
value="sync"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<Cloud size={14} /> {t("settings.tab.syncCloud")}
<Cloud size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
</TabsTrigger>
<TabsTrigger
value="system"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
className={settingsTabTriggerClassName}
>
<HardDrive size={14} /> {t("settings.tab.system")}
<HardDrive size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.system")}</span>
</TabsTrigger>
</TabsList>
</div>

View File

@@ -19,7 +19,7 @@ import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
@@ -135,6 +135,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -576,18 +577,35 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const connection = sftpRef.current.leftPane.connection;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(revealPath);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
[openPath, t],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
@@ -608,6 +626,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
[sftp.leftPane.connection],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
// When the auto-connect effect defers a switch (active transfers or open
// editor), the panel still operates on the current connection, not
// activeHost. Use the connected host for the header so the label matches
@@ -706,6 +742,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
/>
</div>
@@ -715,6 +753,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}

View File

@@ -136,3 +136,31 @@ test("keeps reveal target and child toggle as separate buttons", () => {
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});
test("renders explicit target actions for completed local downloads", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "download-1",
fileName: "report.pdf",
sourcePath: "/remote/report.pdf",
targetPath: "/Users/alice/Downloads/report.pdf",
targetConnectionId: "local",
direction: "download",
status: "completed",
error: undefined,
transferredBytes: 1024,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canCopyTargetPath: true,
onCopyTargetPath: () => {},
},
);
assert.match(markup, /aria-label="Open target folder: report\.pdf"/);
assert.match(markup, /aria-label="Copy target path: report\.pdf"/);
assert.match(markup, /lucide-folder-open/);
assert.match(markup, /lucide-clipboard-copy/);
});

View File

@@ -19,12 +19,13 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -137,6 +138,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -271,6 +273,75 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
[sftp.transfers],
);
const getTransferTargetDirectory = useCallback(
(task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)),
[],
);
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
const state = sftpRef.current;
for (const side of ["left", "right"] as const) {
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
if (pane?.connection && !pane.connection.isLocal) {
return { side, tabId: pane.id };
}
}
return null;
}, []);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
return !!findRemoteTransferTargetTab(task);
},
[findRemoteTransferTargetTab],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const targetDirectory = getTransferTargetDirectory(task);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(targetDirectory);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
const targetTab = findRemoteTransferTargetTab(task);
if (!targetTab) return;
await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId });
},
[findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
const containerStyle: React.CSSProperties = isActive
? {}
: {
@@ -475,6 +546,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}

View File

@@ -50,10 +50,16 @@ import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
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 {
createPromptLineBreakState,
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./terminal/runtime/promptLineBreak";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -64,6 +70,8 @@ import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
/**
* Extract unique root paths from drop entries for local terminal path insertion.
* For nested files, extracts the root folder path; for single files, uses the full path.
@@ -285,8 +293,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const terminalDataCapturedRef = useRef(false);
const terminalLogDataRef = useRef("");
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
@@ -300,6 +311,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
const captureTerminalLogData = useCallback((data: string) => {
const replaySafeData = terminalLogSanitizerRef.current.append(data);
if (!replaySafeData) return;
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
}, []);
const finalizeTerminalLogData = useCallback(() => {
const replaySafeData = terminalLogSanitizerRef.current.finish();
if (replaySafeData) {
terminalLogDataRef.current += replaySafeData;
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
}
}
return terminalLogDataRef.current;
}, []);
const writeLocalTerminalData = useCallback((data: string) => {
if (!data) return;
captureTerminalLogData(data);
termRef.current?.write(data);
}, [captureTerminalLogData]);
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
@@ -437,20 +474,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const line = serialLineBufferRef.current + "\r";
terminalBackend.writeToSession(id, line);
serialLineBufferRef.current = "";
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
if (serialConfig?.localEcho) writeLocalTerminalData("\r\n");
} else if (ch === "\x15") {
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
writeLocalTerminalData("\b \b".repeat(serialLineBufferRef.current.length));
}
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
if (serialConfig?.localEcho) termRef.current?.write(ch);
if (serialConfig?.localEcho) writeLocalTerminalData(ch);
}
}
// Still update commandBuffer and broadcast for serial line mode
@@ -460,9 +497,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend.writeToSession(id, text);
for (const ch of text) {
if (ch === "\r") {
termRef.current?.write("\r\n");
writeLocalTerminalData("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
termRef.current?.write(ch);
writeLocalTerminalData(ch);
}
}
} else {
@@ -480,6 +517,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
markPromptLineBreakCommandPending(promptLineBreakStateRef);
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
@@ -754,12 +792,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
const captureHandler = onTerminalDataCaptureRef.current;
if (!captureHandler || terminalDataCapturedRef.current) return;
terminalDataCapturedRef.current = true;
captureHandler(capturedSessionId, data);
}, []);
const replaySafeLogData = finalizeTerminalLogData();
const capturedData = replaySafeLogData || data;
captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
const cleanupSession = () => {
disposeDataRef.current?.();
@@ -811,6 +852,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
fitAddonRef,
serializeAddonRef,
pendingAuthRef,
promptLineBreakStateRef,
updateStatus,
setStatus,
setError,
@@ -847,6 +889,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
onSessionExit,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalLogData: captureTerminalLogData,
onOsDetected,
onCommandExecuted,
sessionLog,
@@ -856,6 +899,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
terminalLogDataRef.current = "";
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
@@ -863,6 +908,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
promptLineBreakStateRef.current = createPromptLineBreakState();
const boot = async () => {
try {
@@ -887,11 +933,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
statusRef,
onCommandExecuted,
commandBufferRef,
promptLineBreakStateRef,
setIsSearchOpen,
// Serial-specific options
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onTerminalLogData: captureTerminalLogData,
onCwdChange: (cwd: string) => {
knownCwdRef.current = cwd;
},
@@ -1396,6 +1444,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleContextMenuCapture = (e: MouseEvent) => {
if (!mouseTrackingRef.current) return;
if (statusRef.current !== 'connected') return;
e.preventDefault();
e.stopImmediatePropagation();
@@ -1419,7 +1468,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current) {
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
e.stopImmediatePropagation();
}
};
@@ -1506,9 +1555,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalContextActions = useTerminalContextActions({
termRef,
sourceSessionId: sessionId,
sessionRef,
onHasSelectionChange: setHasSelection,
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
});
// Kept fresh on every render so the mouseTracking capture handler at
// handleContextMenuCapture (which is bound once per sessionId) can
@@ -1820,6 +1872,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSelectWord={terminalContextActions.onSelectWord}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
isReconnectable={status === "disconnected"}
onReconnect={handleRetry}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@ interface TextEditorModalProps {
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,

View File

@@ -10,7 +10,7 @@ import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
const ThemeItem = memo(({
theme,
isSelected,
onSelect

File diff suppressed because it is too large Load Diff

View File

@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
);
};
export default TrafficDiagram;

View File

@@ -70,7 +70,6 @@ import {
SSHKey,
ShellHistoryEntry,
Snippet,
TerminalSession,
} from "../types";
import { AppLogo } from "./AppLogo";
import { DistroAvatar } from "./DistroAvatar";
@@ -135,7 +134,7 @@ interface VaultViewProps {
shellHistory: ShellHistoryEntry[];
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessions: TerminalSession[];
sessionCount: number;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
terminalThemeId: string;
@@ -187,7 +186,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
shellHistory,
connectionLogs,
managedSources,
sessions,
sessionCount,
hotkeyScheme,
keyBindings,
terminalThemeId,
@@ -2511,7 +2510,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
{t("vault.hosts.header.live", { count: sessionCount })}
</div>
</div>
</div>
@@ -3291,7 +3290,7 @@ export const vaultViewAreEqual = (
prev.knownHosts === next.knownHosts &&
prev.shellHistory === next.shellHistory &&
prev.connectionLogs === next.connectionLogs &&
prev.sessions === next.sessions &&
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&

View File

@@ -4,6 +4,7 @@ import { code } from '@streamdown/code';
import type { ComponentProps, HTMLAttributes } from 'react';
import { memo } from 'react';
import { Streamdown } from 'streamdown';
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
@@ -46,21 +47,8 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
className,
)}
{...props}
>
{children}
</div>
);
const streamdownPlugins = { cjk, code };
const safeCode = createSafeCodeHighlighter(code);
const streamdownPlugins = { cjk, code: safeCode };
export type MessageResponseProps = ComponentProps<typeof Streamdown>;

View File

@@ -11,7 +11,6 @@ import type {
FormEvent,
HTMLAttributes,
KeyboardEvent,
ReactNode,
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
@@ -145,37 +144,6 @@ export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps
);
PromptInputTools.displayName = 'PromptInputTools';
// ---------------------------------------------------------------------------
// PromptInputButton (toolbar button with optional tooltip)
// ---------------------------------------------------------------------------
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
tooltip?: ReactNode;
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
}
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
const button = <InputGroupButton ref={ref} {...props} />;
if (!tooltip) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputButton.displayName = 'PromptInputButton';
// ---------------------------------------------------------------------------
// PromptInputSubmit
// ---------------------------------------------------------------------------
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
@@ -244,4 +212,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
},
);
PromptInputSubmit.displayName = 'PromptInputSubmit';

View File

@@ -0,0 +1,76 @@
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import type { BundledLanguage } from 'shiki';
const PLAIN_TEXT_LANGUAGES = new Set([
'',
'plain',
'plaintext',
'text',
'txt',
]);
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
cfg: 'ini',
conf: 'ini',
config: 'ini',
};
export const createPlainCodeHighlightResult = (source: string): HighlightResult => {
const code = source.replace(/\n+$/, '');
return {
bg: 'transparent',
fg: 'inherit',
tokens: code.split('\n').map((line) => [
{
content: line,
color: 'inherit',
bgColor: 'transparent',
htmlStyle: {},
offset: 0,
},
]),
};
};
const normalizeLanguageKey = (language: string): string =>
language.trim().toLowerCase();
export const resolveSupportedCodeLanguage = (
highlighter: CodeHighlighterPlugin,
language: string,
): BundledLanguage | null => {
const key = normalizeLanguageKey(language);
if (PLAIN_TEXT_LANGUAGES.has(key)) return null;
const direct = key as BundledLanguage;
if (highlighter.supportsLanguage(direct)) return direct;
const alias = LANGUAGE_ALIASES[key];
if (alias && highlighter.supportsLanguage(alias)) return alias;
return null;
};
export const createSafeCodeHighlighter = (
highlighter: CodeHighlighterPlugin,
): CodeHighlighterPlugin => ({
...highlighter,
supportsLanguage(language) {
return resolveSupportedCodeLanguage(highlighter, language) !== null;
},
highlight(options: HighlightOptions, callback?: (result: HighlightResult) => void) {
const supportedLanguage = resolveSupportedCodeLanguage(highlighter, options.language);
if (!supportedLanguage) {
return createPlainCodeHighlightResult(options.code);
}
return highlighter.highlight(
{ ...options, language: supportedLanguage },
callback,
);
},
});

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import {
createPlainCodeHighlightResult,
createSafeCodeHighlighter,
resolveSupportedCodeLanguage,
} from '../ai-elements/streamdownCodeHighlighter';
const createFakeHighlighter = (
supportedLanguages: string[],
highlightImpl?: CodeHighlighterPlugin['highlight'],
): CodeHighlighterPlugin => ({
name: 'shiki',
type: 'code-highlighter',
getSupportedLanguages: () => supportedLanguages as ReturnType<CodeHighlighterPlugin['getSupportedLanguages']>,
getThemes: () => ['github-light', 'github-dark'],
supportsLanguage: (language) => supportedLanguages.includes(language),
highlight: highlightImpl ?? ((options: HighlightOptions): HighlightResult => ({
tokens: [[{ content: options.language, offset: 0 }]],
})),
});
test('maps generic conf fences to ini for Streamdown highlighting', () => {
const highlighter = createFakeHighlighter(['ini']);
assert.equal(resolveSupportedCodeLanguage(highlighter, 'conf'), 'ini');
assert.equal(resolveSupportedCodeLanguage(highlighter, ' config '), 'ini');
});
test('falls back to plain tokens for unsupported languages', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter([], () => {
throw new Error('delegate should not be called for unsupported languages');
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd"\n Target="10.185.3.1")\n',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line.map((token) => token.content).join('')),
['*.* action(type="omfwd"', ' Target="10.185.3.1")'],
);
});
test('uses supported aliases when highlighting generic config blocks', () => {
let receivedLanguage: string | null = null;
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], (options: HighlightOptions): HighlightResult => {
receivedLanguage = options.language;
return createPlainCodeHighlightResult(options.code);
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd")',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.equal(receivedLanguage, 'ini');
assert.equal(result?.tokens[0][0].content, '*.* action(type="omfwd")');
});
test('treats text fences as plain code without calling the delegate', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], () => {
throw new Error('delegate should not be called for text fences');
}),
);
const result = highlighter.highlight({
code: 'hello\nworld',
language: 'text',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line[0].content),
['hello', 'world'],
);
});

View File

@@ -6,8 +6,7 @@
// Utilities and types
export {
copyToClipboard,detectKeyType,generateMockKeyPair,getKeyIcon,
getKeyTypeDisplay,isMacOS,type FilterTab,type PanelMode
isMacOS,type FilterTab,type PanelMode
} from './utils';
// Card components

View File

@@ -7,33 +7,6 @@ import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType, SSHKey } from '../../types';
/**
* Generate mock key pair (for fallback when Electron backend is unavailable)
*/
export const generateMockKeyPair = (type: KeyType, label: string, keySize?: number): { privateKey: string; publicKey: string } => {
const typeMap: Record<KeyType, string> = {
'ED25519': 'ed25519',
'ECDSA': `ecdsa-sha2-nistp${keySize || 256}`,
'RSA': 'rsa',
};
const randomId = crypto.randomUUID().replace(/-/g, '').substring(0, 32);
// Generate size-appropriate random data for more realistic keys
const keyLength = type === 'RSA' ? (keySize || 4096) / 8 : 32;
const randomData = Array.from(crypto.getRandomValues(new Uint8Array(keyLength)))
.map(b => b.toString(16).padStart(2, '0')).join('');
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB${randomId}AAAEC${randomData.substring(0, 64)}
-----END OPENSSH PRIVATE KEY-----`;
const publicKey = `ssh-${typeMap[type]} AAAAC3NzaC1lZDI1NTE5AAAAI${randomId.substring(0, 20)} ${label}@netcatty`;
return { privateKey, publicKey };
};
/**
* Get icon element for key source
*/

View File

@@ -1,29 +1,17 @@
/**
* Port Forwarding components module
* Re-exports all port forwarding sub-components
* Re-exports the entries consumed by the top-level port forwarding view.
*/
export {
TYPE_DESCRIPTION_KEYS,
TYPE_LABEL_KEYS,
TYPE_MENU_LABEL_KEYS,
TYPE_ICONS,
generateRuleLabel,
getStatusColor,
getTypeColor,
getTypeDescription,
getTypeLabel,
getTypeMenuLabel,
} from './utils';
export { RuleCard } from './RuleCard';
export type { RuleCardProps,ViewMode } from './RuleCard';
export { WizardContent } from './WizardContent';
export type { WizardContentProps,WizardStep } from './WizardContent';
export { EditPanel } from './EditPanel';
export type { EditPanelProps } from './EditPanel';
export { NewFormPanel } from './NewFormPanel';
export type { NewFormPanelProps } from './NewFormPanel';

View File

@@ -1,23 +1,21 @@
/**
* Port Forwarding utilities and constants
*/
import { Globe,Server,Shuffle } from 'lucide-react';
import React from 'react';
import { PortForwardingType } from '../../domain/models';
export const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
const TYPE_LABEL_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.local',
remote: 'pf.type.remote',
dynamic: 'pf.type.dynamic',
};
export const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
const TYPE_MENU_LABEL_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.menu.local',
remote: 'pf.type.menu.remote',
dynamic: 'pf.type.menu.dynamic',
};
export const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
const TYPE_DESCRIPTION_KEYS: Record<PortForwardingType, string> = {
local: 'pf.type.local.desc',
remote: 'pf.type.remote.desc',
dynamic: 'pf.type.dynamic.desc',
@@ -44,12 +42,6 @@ export function getTypeDescription(
return t(TYPE_DESCRIPTION_KEYS[type]);
}
export const TYPE_ICONS: Record<PortForwardingType, React.ReactNode> = {
local: <Globe size={16} />,
remote: <Server size={16} />,
dynamic: <Shuffle size={16} />,
};
/**
* Get status color class for a rule
*/

View File

@@ -6,12 +6,11 @@ import {
buildLocalVaultPayload,
buildSyncPayload,
applySyncPayload,
getEffectivePortForwardingRulesForSync,
} from "../../../application/syncPayload";
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
@@ -35,28 +34,7 @@ export default function SettingsSyncTab(props: {
const { t } = useI18n();
const getEffectivePortForwardingRules = useCallback((): PortForwardingRule[] => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
let effectiveRules = portForwardingRules;
if (effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
// Strip transient per-device fields (status, error, lastUsedAt)
// that setGlobalRules persists to localStorage but shouldn't be
// included in the cloud sync snapshot.
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
...rest,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return effectiveRules;
return getEffectivePortForwardingRulesForSync(portForwardingRules) ?? [];
}, [portForwardingRules]);
const onBuildPayload = useCallback((): SyncPayload => {

View File

@@ -890,6 +890,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.forcePromptNewLine")}
description={t("settings.terminal.behavior.forcePromptNewLine.desc")}
>
<Toggle checked={terminalSettings.forcePromptNewLine ?? true} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}

View File

@@ -1,9 +0,0 @@
export { ProviderIconBadge } from "./ProviderIconBadge";
export { ModelSelector } from "./ModelSelector";
export { ProviderConfigForm } from "./ProviderConfigForm";
export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -104,12 +104,6 @@ export const useActiveTabId = (side: "left" | "right"): string | null => {
);
};
// Hook to check if a specific pane is active (for CSS control)
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
const activeTabId = useActiveTabId(side);
return activeTabId === paneId || (activeTabId === null && paneId !== null);
};
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];

View File

@@ -3,10 +3,13 @@ import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
import type { TransferTask } from "../../types";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
import { SftpConflictDialog } from "./SftpConflictDialog";
import { SftpHostPicker } from "./SftpHostPicker";
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
import { SftpTransferQueue } from "./SftpTransferQueue";
type SftpState = ReturnType<typeof useSftpState>;
@@ -16,6 +19,10 @@ interface SftpOverlaysProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showTransferQueue?: boolean;
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
canCopyTransferTargetPath?: (task: TransferTask) => boolean;
onCopyTransferTargetPath?: (task: TransferTask) => void | Promise<void>;
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
@@ -54,6 +61,10 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
sftp,
visibleTransfers,
showTransferQueue = true,
canRevealTransferTarget,
onRevealTransferTarget,
canCopyTransferTargetPath,
onCopyTransferTargetPath,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
@@ -111,7 +122,15 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
/>
{showTransferQueue && (
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} allTransfers={sftp.transfers} />
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={onRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={onCopyTransferTargetPath}
/>
)}
<SftpConflictDialog

View File

@@ -12,7 +12,7 @@ import {
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./index";
import { SftpHostPicker } from "./SftpHostPicker";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {

View File

@@ -14,10 +14,9 @@ import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
import { isNavigableDirectory } from "./index";
import { buildSftpColumnTemplate, isNavigableDirectory, type ColumnWidths, type SortField, type SortOrder } from "./utils";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
import { SftpFileRow } from "./SftpFileRow";
import {
getSftpListUploadFilesTargetPath,
getSftpUploadFilesLabelKey,

View File

@@ -6,7 +6,7 @@ import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/pop
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { SftpBreadcrumb } from "./index";
import { SftpBreadcrumb } from "./SftpBreadcrumb";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";

View File

@@ -15,7 +15,7 @@ import {
useSftpPaneCallbacks,
useSftpUpdateHosts,
useSftpWritableHosts,
} from "./index";
} from "./SftpContext";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";

View File

@@ -8,7 +8,9 @@ import {
CheckCircle2,
ChevronDown,
ChevronUp,
ClipboardCopy,
File,
FolderOpen,
FolderUp,
GripVertical,
Loader2,
@@ -35,6 +37,8 @@ interface SftpTransferItemProps {
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
canCopyTargetPath?: boolean;
onCopyTargetPath?: () => void;
canToggleChildren?: boolean;
isExpanded?: boolean;
visibleChildCount?: number;
@@ -84,6 +88,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
onDismiss,
canRevealTarget = false,
onRevealTarget,
canCopyTargetPath = false,
onCopyTargetPath,
canToggleChildren = false,
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
@@ -209,6 +215,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const dismissActionLabel = t('sftp.transfers.dismissAction');
const resizeNameColumnLabel = t('sftp.transfers.resizeNameColumn');
const toggleChildrenLabel = isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList');
const revealTargetLabel = t('sftp.transfers.openTargetFolder');
const copyTargetPathLabel = t('sftp.transfers.copyTargetPath');
const actionButtonClass = "h-6 w-6 focus-visible:ring-1 focus-visible:ring-primary/50";
const actionAriaLabel = (label: string) => `${label}: ${task.fileName}`;
@@ -238,6 +246,20 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{canRevealTarget && onRevealTarget && (
<IconButtonWithTooltip label={revealTargetLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRevealTarget} aria-label={actionAriaLabel(revealTargetLabel)}>
<FolderOpen size={12} />
</Button>
</IconButtonWithTooltip>
)}
{canCopyTargetPath && onCopyTargetPath && (
<IconButtonWithTooltip label={copyTargetPathLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onCopyTargetPath} aria-label={actionAriaLabel(copyTargetPathLabel)}>
<ClipboardCopy size={12} />
</Button>
</IconButtonWithTooltip>
)}
{task.status === 'failed' && task.retryable !== false && (
<IconButtonWithTooltip label={retryActionLabel}>
<Button variant="ghost" size="icon" className={actionButtonClass} onClick={onRetry} aria-label={actionAriaLabel(retryActionLabel)}>
@@ -355,6 +377,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
type="button"
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
aria-label={actionAriaLabel(revealTargetLabel)}
>
{titleBlock}
</button>
@@ -440,6 +463,7 @@ const arePropsEqual = (
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
if ((prevProps.canCopyTargetPath ?? false) !== (nextProps.canCopyTargetPath ?? false)) return false;
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false;
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;

View File

@@ -20,6 +20,8 @@ interface SftpTransferQueueProps {
allTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
canCopyTransferTargetPath?: (task: TransferTask) => boolean;
onCopyTransferTargetPath?: (task: TransferTask) => void | Promise<void>;
}
const MIN_PANEL_HEIGHT = 112;
@@ -151,6 +153,8 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
allTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
canCopyTransferTargetPath,
onCopyTransferTargetPath,
}) => {
const { t } = useI18n();
const [expandedParents, setExpandedParents] = useState<Record<string, boolean>>({});
@@ -417,6 +421,14 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
}
: undefined
}
canCopyTargetPath={canCopyTransferTargetPath?.(task) ?? false}
onCopyTargetPath={
onCopyTransferTargetPath
? () => {
void onCopyTransferTargetPath(task);
}
: undefined
}
/>
{isExpanded && childTasks.length > 0 && (

View File

@@ -1,44 +1,10 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
/** Re-read bookmarks from localStorage (e.g. after cloud sync import). */
export function rehydrateGlobalBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const l of listeners) l();
}
// Rehydrate when another window updates the same localStorage key
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalBookmarks();
}
});
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
window.dispatchEvent(new CustomEvent('sftp-bookmarks-changed'));
}
import {
getGlobalSftpBookmarksSnapshot,
setGlobalSftpBookmarks,
subscribeGlobalSftpBookmarks,
} from "../../../application/state/sftp/globalSftpBookmarks";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
@@ -47,7 +13,11 @@ interface UseGlobalSftpBookmarksParams {
export const useGlobalSftpBookmarks = ({
currentPath,
}: UseGlobalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const bookmarks = useSyncExternalStore(
subscribeGlobalSftpBookmarks,
getGlobalSftpBookmarksSnapshot,
getGlobalSftpBookmarksSnapshot,
);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
@@ -57,21 +27,11 @@ export const useGlobalSftpBookmarks = ({
const addBookmark = useCallback((path: string) => {
if (!path) return;
if (bookmarks.some((b) => b.path === path)) return;
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
const label = isRoot
? path
: path.split(/[\\/]/).filter(Boolean).pop() || path;
const newBookmark: SftpBookmark = {
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label,
global: true,
};
setBookmarks((prev) => [...prev, newBookmark]);
setGlobalSftpBookmarks((prev) => [...prev, createSftpBookmark(path, { global: true })]);
}, [bookmarks]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
setGlobalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
// ── Shared external store so every hook instance sees the same bookmarks ──
@@ -47,16 +48,7 @@ export const useLocalSftpBookmarks = ({
if (isCurrentPathBookmarked) {
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
const label = isRoot
? currentPath
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
const newBookmark: SftpBookmark = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path: currentPath,
label,
};
setBookmarks((prev) => [...prev, newBookmark]);
setBookmarks((prev) => [...prev, createSftpBookmark(currentPath)]);
}
}, [currentPath, isCurrentPathBookmarked]);

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import type { Host, SftpBookmark } from "../../../domain/models";
import { createSftpBookmark } from "../../../application/state/sftp/bookmarkHelpers";
interface UseSftpBookmarksParams {
host: Host | undefined;
@@ -40,16 +41,7 @@ export const useSftpBookmarks = ({
if (isCurrentPathBookmarked) {
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
} else {
const label =
currentPath === "/"
? "/"
: currentPath.split("/").filter(Boolean).pop() || currentPath;
const newBookmark: SftpBookmark = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path: currentPath,
label,
};
updateHostBookmarks([...bookmarks, newBookmark]);
updateHostBookmarks([...bookmarks, createSftpBookmark(currentPath)]);
}
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);

View File

@@ -16,7 +16,7 @@ import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import type { SftpFileEntry } from "../../../types";
import { toast } from "../../ui/toast";

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../index";
import { isNavigableDirectory } from "../utils";
import { joinPath } from "../../../application/state/sftp/utils";
interface UseSftpPaneDragAndSelectParams {

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles, sortSftpEntries } from "../index";
import { filterHiddenFiles, sortSftpEntries } from "../utils";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;

View File

@@ -6,7 +6,7 @@ import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../index";
import { isNavigableDirectory } from "../utils";
import { editorTabStore } from "../../../application/state/editorTabStore";
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";

View File

@@ -1,38 +1,13 @@
/**
* SFTP Components - Index
*
* Re-exports all SFTP-related components and utilities for easy importing
* Re-exports the SFTP entries consumed by top-level views.
*/
// Utilities
export {
formatBytes, formatDate,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, sortSftpEntries, type ColumnWidths, type SortField,
type SortOrder
} from './utils';
// Context
export {
SftpContextProvider,
useSftpContext,
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpWritableHosts,
useSftpUpdateHosts,
useActiveTabId,
useIsPaneActive,
activeTabStore,
type SftpPaneCallbacks,
type SftpDragCallbacks,
type SftpContextValue,
} from './SftpContext';
// Components
export { SftpBreadcrumb } from './SftpBreadcrumb';
export { SftpConflictDialog } from './SftpConflictDialog';
export { SftpFileRow } from './SftpFileRow';
export { SftpHostPicker } from './SftpHostPicker';
export { SftpPermissionsDialog } from './SftpPermissionsDialog';
export { SftpTabBar, type SftpTab } from './SftpTabBar';
export { SftpTransferItem } from './SftpTransferItem';
export { SftpTabBar } from './SftpTabBar';

View File

@@ -329,7 +329,7 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
*
* The ".." parent directory entry is never considered hidden.
*/
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
file: T,
): boolean => {
if (file.name === "..") return false;
@@ -340,10 +340,6 @@ export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
return false;
};
/** @deprecated Use isHiddenFile instead */
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
isHiddenFile(file);
/**
* Filter files based on hidden file visibility setting.
* Filters Windows hidden files and Unix/Linux dotfiles on all connections.

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import en from "../../application/i18n/locales/en.ts";
import zhCN from "../../application/i18n/locales/zh-CN.ts";
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
const shouldShowReconnectAction = (
terminalContextMenu as {
shouldShowReconnectAction?: (options: {
isReconnectable?: boolean;
onReconnect?: () => void;
}) => boolean;
}
).shouldShowReconnectAction;
const shouldSuppressMouseTrackingContextMenu = (
terminalContextMenu as {
shouldSuppressMouseTrackingContextMenu?: (options: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}) => boolean;
}
).shouldSuppressMouseTrackingContextMenu;
test("shows reconnect only for reconnectable terminals with a handler", () => {
assert.equal(typeof shouldShowReconnectAction, "function");
if (typeof shouldShowReconnectAction !== "function") return;
assert.equal(
shouldShowReconnectAction({
isReconnectable: true,
onReconnect: () => {},
}),
true,
);
assert.equal(
shouldShowReconnectAction({
isReconnectable: false,
onReconnect: () => {},
}),
false,
);
assert.equal(shouldShowReconnectAction({ isReconnectable: true }), false);
});
test("localizes the reconnect context menu label", () => {
assert.equal(en["terminal.menu.reconnect"], "Reconnect");
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
});
test("allows reconnect menu while stale mouse tracking is still active", () => {
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
if (typeof shouldSuppressMouseTrackingContextMenu !== "function") return;
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: true,
}),
false,
);
assert.equal(
shouldSuppressMouseTrackingContextMenu({
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
});

View File

@@ -5,6 +5,7 @@
import {
ClipboardPaste,
Copy,
RefreshCcw,
SplitSquareHorizontal,
SplitSquareVertical,
Terminal as TerminalIcon,
@@ -36,10 +37,28 @@ export interface TerminalContextMenuProps {
onClear?: () => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
isReconnectable?: boolean;
onReconnect?: () => void;
onClose?: () => void;
onSelectWord?: () => void;
}
export const shouldShowReconnectAction = ({
isReconnectable,
onReconnect,
}: {
isReconnectable?: boolean;
onReconnect?: () => void;
}): boolean => Boolean(isReconnectable && onReconnect);
export const shouldSuppressMouseTrackingContextMenu = ({
isAlternateScreen,
showReconnectAction,
}: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
@@ -54,6 +73,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onClear,
onSplitHorizontal,
onSplitVertical,
isReconnectable,
onReconnect,
onClose,
onSelectWord,
}) => {
@@ -88,6 +109,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
@@ -95,8 +117,9 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) {
// handle right-click natively to avoid conflicting menus. Reconnect is
// still available after disconnect, even if mouse tracking was left on.
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
e.preventDefault();
return;
}
@@ -120,7 +143,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, showReconnectAction],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure
@@ -133,7 +156,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
>
{children}
</ContextMenuTrigger>
{!isAlternateScreen && (
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
@@ -158,6 +181,16 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{showReconnectAction && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onReconnect}>
<RefreshCcw size={14} className="mr-2" />
{t('terminal.menu.reconnect')}
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={onSplitVertical}>

View File

@@ -388,17 +388,6 @@ function fuzzyScore(query: string, target: string): number {
return queryIdx === query.length ? score : 0;
}
/**
* Delete a specific command from history for a host.
*/
export function deleteHistoryEntry(command: string, hostId: string): void {
const store = loadStore();
store.entries = store.entries.filter(
(e) => !(e.command === command && e.hostId === hostId),
);
saveStore(store);
}
/**
* Clear all history for a specific host, or all history if no hostId given.
*/
@@ -411,14 +400,3 @@ export function clearHistory(hostId?: string): void {
}
saveStore(store);
}
/**
* Get total number of stored history entries.
*/
export function getHistoryCount(hostId?: string): number {
const store = loadStore();
if (hostId) {
return store.entries.filter((e) => e.hostId === hostId).length;
}
return store.entries.length;
}

View File

@@ -238,6 +238,7 @@ export async function getCompletions(
? await getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
os: options.os,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})

View File

@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
export { recordCommand, clearHistory } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";

View File

@@ -362,22 +362,3 @@ export function getAlignedPrompt(
}
return { prompt: raw, alignedTyped: null };
}
/**
* Simplified prompt detection: just check if we're likely at a prompt.
*/
export function isLikelyAtPrompt(term: XTerm): boolean {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return false;
const lineText = line.translateToString(false);
if (lineText.trim().length === 0) return false;
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return false;
}
return findPromptBoundary(lineText) >= 0;
}

View File

@@ -13,6 +13,10 @@ export interface DirEntry {
type: "file" | "directory" | "symlink";
}
interface ResolvePathOptions {
preferRelativeCwd?: boolean;
}
/** Bridge interface for directory listing */
interface PathBridge {
listAutocompleteRemoteDir?: (
@@ -130,18 +134,20 @@ export function shouldDoPathCompletion(
export function resolvePathComponents(
currentWord: string,
cwd: string | undefined,
options: ResolvePathOptions = {},
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
const quotePrefix = getLeadingQuote(currentWord);
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
const unquotedWord = stripWrappingQuotes(currentWord);
const preferRelativeCwd = options.preferRelativeCwd === true;
// Handle empty input — list CWD
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
const dir = unquotedWord === "~"
? "~"
: unquotedWord === ".."
? resolveDirLookup("../", cwd)
: (cwd || ".");
? resolveDirLookup("../", cwd, preferRelativeCwd)
: resolveDirLookup("", cwd, preferRelativeCwd);
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
}
@@ -155,22 +161,26 @@ export function resolvePathComponents(
const decodedDirPart = decodeShellPathFragment(dirPart);
const decodedFilterPart = decodeShellPathFragment(filterPart);
const dirToList = resolveDirLookup(decodedDirPart, cwd);
const dirToList = resolveDirLookup(decodedDirPart, cwd, preferRelativeCwd);
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
}
// No slash — filter CWD entries by the typed prefix
return {
dirToList: cwd || ".",
dirToList: resolveDirLookup("", cwd, preferRelativeCwd),
filterPrefix: decodeShellPathFragment(unquotedWord),
pathPrefix: quotePrefix,
quoteSuffix,
};
}
export function normalizePathTokenForLookup(token: string, cwd?: string): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd);
export function normalizePathTokenForLookup(
token: string,
cwd?: string,
options: ResolvePathOptions = {},
): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd, options);
if (!filterPrefix) return dirToList;
if (!dirToList || dirToList === ".") {
@@ -189,16 +199,20 @@ export async function getPathSuggestions(
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
cwd?: string;
foldersOnly: boolean;
},
): Promise<{ name: string; type: DirEntry["type"] }[]> {
const { sessionId, protocol, cwd, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd);
const { sessionId, protocol, os, cwd, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd, {
preferRelativeCwd: shouldUseRemoteShellCwd(protocol, sessionId, os),
});
const entries = await listDirectoryEntries(dirToList, {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix,
limit: 100,
@@ -215,6 +229,7 @@ export async function listDirectoryEntries(
options: {
sessionId?: string;
protocol?: string;
os?: "linux" | "windows" | "macos";
foldersOnly: boolean;
filterPrefix?: string;
limit?: number;
@@ -223,6 +238,7 @@ export async function listDirectoryEntries(
const {
sessionId,
protocol,
os,
foldersOnly,
filterPrefix = "",
limit = 100,
@@ -232,28 +248,32 @@ export async function listDirectoryEntries(
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
const fullCacheKey = `${baseKey}:all`;
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
const bypassCache = shouldBypassCache(protocol, sessionId, os, dirPath);
// Full directory cache can satisfy both full and filtered lookups.
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
if (!bypassCache) {
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
}
const inFlight = inFlightRequests.get(normalizedPrefix ? filteredCacheKey : fullCacheKey);
if (inFlight) return inFlight;
}
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
const inFlight = inFlightRequests.get(requestKey);
if (inFlight) return inFlight;
// Make IPC call
const promise = (async (): Promise<DirEntry[]> => {
@@ -284,6 +304,9 @@ export async function listDirectoryEntries(
if (result.success) {
const timestamp = Date.now();
if (bypassCache) {
return result.entries;
}
if (normalizedPrefix) {
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
@@ -299,11 +322,15 @@ export async function listDirectoryEntries(
} catch {
return [];
} finally {
inFlightRequests.delete(requestKey);
if (!bypassCache) {
inFlightRequests.delete(requestKey);
}
}
})();
inFlightRequests.set(requestKey, promise);
if (!bypassCache) {
inFlightRequests.set(requestKey, promise);
}
return promise;
}
@@ -312,14 +339,33 @@ function clampLimit(limit: number): number {
return Math.max(1, Math.min(200, Math.floor(limit)));
}
function resolveDirLookup(pathToken: string, cwd: string | undefined): string {
if (!pathToken) return cwd || ".";
function resolveDirLookup(pathToken: string, cwd: string | undefined, preferRelativeCwd = false): string {
if (!pathToken) return preferRelativeCwd ? "." : (cwd || ".");
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
if (preferRelativeCwd) return normalizePosixLikePath(pathToken);
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
return normalizePosixLikePath(pathToken);
}
function shouldUseRemoteShellCwd(
protocol: string | undefined,
sessionId: string | undefined,
os?: "linux" | "windows" | "macos",
): boolean {
return Boolean(sessionId && protocol !== "local" && os === "linux");
}
function shouldBypassCache(
protocol: string | undefined,
sessionId: string | undefined,
os: "linux" | "windows" | "macos" | undefined,
dirPath: string,
): boolean {
if (!shouldUseRemoteShellCwd(protocol, sessionId, os)) return false;
return !dirPath.startsWith("/") && dirPath !== "~" && !dirPath.startsWith("~/");
}
function normalizePosixLikePath(input: string): string {
if (!input) return ".";

View File

@@ -275,6 +275,7 @@ export function useTerminalAutocomplete(
return listDirectoryEntries(dirPath, {
sessionId: sessionIdRef.current,
protocol: protocolRef.current,
os: hostOsRef.current,
foldersOnly: false,
limit: 50,
});
@@ -308,7 +309,11 @@ export function useTerminalAutocomplete(
getCwdRef.current?.(),
hostOsRef.current,
);
const dirPath = normalizePathTokenForLookup(parseCommandLine(item.text).currentWord, cwd);
const dirPath = normalizePathTokenForLookup(parseCommandLine(item.text).currentWord, cwd, {
preferRelativeCwd: Boolean(
sessionIdRef.current && protocolRef.current !== "local" && hostOsRef.current === "linux",
),
});
if (!dirPath) return;
const requestVersion = ++subDirFetchVersionRef.current;

View File

@@ -52,8 +52,14 @@ const storySpec: FigSpec = {
},
],
};
const bridgeState: { localEntries: MockDirEntry[] } = {
const bridgeState: {
localEntries: MockDirEntry[];
remoteEntriesByPath: Map<string, MockDirEntry[]>;
remoteCalls: string[];
} = {
localEntries: [],
remoteEntriesByPath: new Map(),
remoteCalls: [],
};
Object.defineProperty(globalThis, "window", {
@@ -74,6 +80,22 @@ Object.defineProperty(globalThis, "window", {
.slice(0, limit ?? bridgeState.localEntries.length);
return { success: true, entries };
},
listAutocompleteRemoteDir: async (
_sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => {
bridgeState.remoteCalls.push(path);
const prefix = (filterPrefix ?? "").toLowerCase();
const remoteEntries = bridgeState.remoteEntriesByPath.get(path) ?? [];
const entries = remoteEntries
.filter((entry) => !foldersOnly || entry.type === "directory")
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
.slice(0, limit ?? remoteEntries.length);
return { success: true, entries };
},
},
},
configurable: true,
@@ -86,6 +108,8 @@ test.beforeEach(() => {
localStorage.clear();
clearHistory();
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
bridgeState.remoteEntriesByPath = new Map();
bridgeState.remoteCalls = [];
});
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
@@ -121,3 +145,44 @@ test("getCompletions does not treat generator-only spec args as path contexts",
assert.equal(completions[0]?.text, "story pick package-choice");
assert.equal(completions.some((entry) => entry.source === "path"), false);
});
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
cwd: "~",
});
assert.deepEqual(bridgeState.remoteCalls, ["."]);
assert.equal(completions[0]?.source, "path");
assert.equal(completions[0]?.text, "cat worktree.txt");
assert.equal(completions.some((entry) => entry.text.includes("~")), false);
});
test("getCompletions does not reuse cached remote relative listings after cwd changes", async () => {
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
await getCompletions("cat ", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
});
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
const completions = await getCompletions("cat wo", {
hostId: "host-1",
os: "linux",
protocol: "ssh",
sessionId: "session-1",
});
assert.equal(bridgeState.remoteCalls.length, 2);
assert.equal(completions[0]?.text, "cat worktree.txt");
});

View File

@@ -5,17 +5,50 @@ import { logger } from "../../../lib/logger";
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
import { clearTerminalViewport } from "../clearTerminalViewport";
type BroadcastPasteRefs = {
sourceSessionId: string;
sessionRef: RefObject<string | null>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
};
export const broadcastTerminalPasteData = (
data: string,
{ sourceSessionId, sessionRef, isBroadcastEnabledRef, onBroadcastInputRef }: BroadcastPasteRefs,
): boolean => {
if (sessionRef.current && isBroadcastEnabledRef?.current && onBroadcastInputRef?.current) {
onBroadcastInputRef.current(data, sourceSessionId);
return true;
}
return false;
};
export const useTerminalContextActions = ({
termRef,
sourceSessionId,
sessionRef,
onHasSelectionChange,
scrollOnPasteRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
}: {
termRef: RefObject<XTerm | null>;
sourceSessionId: string;
sessionRef: RefObject<string | null>;
onHasSelectionChange?: (hasSelection: boolean) => void;
scrollOnPasteRef?: RefObject<boolean>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
}) => {
const broadcastUserPasteData = useCallback((data: string) => {
return broadcastTerminalPasteData(data, {
sourceSessionId,
sessionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
});
}, [isBroadcastEnabledRef, onBroadcastInputRef, sessionRef, sourceSessionId]);
const onCopy = useCallback(() => {
const term = termRef.current;
if (!term) return;
@@ -33,12 +66,13 @@ export const useTerminalContextActions = ({
if (text && sessionRef.current) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
});
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [sessionRef, termRef, scrollOnPasteRef]);
}, [broadcastUserPasteData, sessionRef, termRef, scrollOnPasteRef]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
@@ -47,8 +81,9 @@ export const useTerminalContextActions = ({
if (!selection || !sessionRef.current) return;
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
});
}, [sessionRef, termRef, scrollOnPasteRef]);
}, [broadcastUserPasteData, sessionRef, termRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;

View File

@@ -0,0 +1,234 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createReplaySafeTerminalLog,
createReplaySafeTerminalLogSanitizer,
} from "./replaySafeTerminalLog";
test("common shell clear sequence is safe to replay", () => {
const log = createReplaySafeTerminalLog("login banner\n$ clear\n\x1b[H\x1b[2J\x1b[3Jafter clear\n");
assert.equal(log, "login banner\n$ clear\n\r\nafter clear\n");
assert.equal(log.includes("\x1b[2J"), false);
assert.equal(log.includes("\x1b[3J"), false);
});
test("display clear followed by cursor home keeps prior replay history", () => {
assert.equal(
createReplaySafeTerminalLog("old1\nold2\n\x1b[2J\x1b[Hnew\n"),
"old1\nold2\n\r\nnew\n",
);
});
test("home erase-to-end clear keeps prior replay history", () => {
assert.equal(
createReplaySafeTerminalLog("before zellij\n$ zellij\n\x1b[H\x1b[Jzellij pane\n"),
"before zellij\n$ zellij\n\r\nzellij pane\n",
);
});
test("repeated home erase-to-end clears create fresh replay sections", () => {
const log = createReplaySafeTerminalLog("history\n\x1b[2Jframe1\x1b[H\x1b[Jframe2\n");
assert.equal(log, "history\n\r\nframe1\r\n\r\nframe2\n");
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("\x1b[J"), false);
});
test("repeated cursor home before clear does not overwrite replay history", () => {
const log = createReplaySafeTerminalLog("old1\nold2\n\x1b[H\x1b[H\x1b[2Jafter\n");
assert.equal(log, "old1\nold2\n\r\nafter\n");
assert.equal(log.includes("\x1b[H"), false);
});
test("mode controls after clear do not allow later home to overwrite history", () => {
const log = createReplaySafeTerminalLog("history\n\x1b[H\x1b[2J\x1b[?25l\x1b[Hnew\n");
assert.equal(log, "history\n\r\n\x1b[?25lnew\n");
});
test("cursor positioning after clear does not overwrite replay history", () => {
const log = createReplaySafeTerminalLog(
"old1\nold2\n\x1b[2J\x1b[10;5Hpanel\x1b[999Aup\x1b[5Fprev\x1b[12drow\x1b[20Gcol\n",
);
assert.equal(log, "old1\nold2\n\r\npanelupprevrowcol\n");
assert.equal(log.includes("\x1b[10;5H"), false);
assert.equal(log.includes("\x1b[999A"), false);
assert.equal(log.includes("\x1b[5F"), false);
assert.equal(log.includes("\x1b[12d"), false);
assert.equal(log.includes("\x1b[20G"), false);
});
test("cursor home after protected clear is dropped when no erase follows", () => {
const log = createReplaySafeTerminalLog("old\n\x1b[2Jframe\x1b[H\x1b[?25ltext\n");
assert.equal(log, "old\n\r\nframe\x1b[?25ltext\n");
assert.equal(log.includes("\x1b[H"), false);
});
test("single-character cursor controls after clear do not overwrite replay history", () => {
const log = createReplaySafeTerminalLog("old\n\x1b[2J\x1bMri\x8dc1ri\x1bDind\x84c1ind\x1bEnel\x85c1nel\n");
assert.equal(log, "old\n\r\nric1riindc1indnelc1nel\n");
assert.equal(log.includes("\x1bM"), false);
assert.equal(log.includes("\x8d"), false);
assert.equal(log.includes("\x1bD"), false);
assert.equal(log.includes("\x84"), false);
assert.equal(log.includes("\x1bE"), false);
assert.equal(log.includes("\x85"), false);
});
test("queued cursor and erase controls before clear are not preserved", () => {
const log = createReplaySafeTerminalLog("old\n\x1b[H\x1b[2;1H\x1b[K\x1b[s\x1b[u\x1b[?25l\x1b[2Jnew\n");
assert.equal(log, "old\n\r\n\x1b[?25lnew\n");
assert.equal(log.includes("\x1b[2;1H"), false);
assert.equal(log.includes("\x1b[K"), false);
assert.equal(log.includes("\x1b[s"), false);
assert.equal(log.includes("\x1b[u"), false);
});
test("cursor save and restore are preserved before any protected clear", () => {
const log = createReplaySafeTerminalLog("abc\x1b[sXYZ\x1b[u!");
assert.equal(log, "abc\x1b[sXYZ\x1b[u!");
});
test("pending cursor-home controls are preserved when no clear follows", () => {
const log = createReplaySafeTerminalLog("abc\x1b[H\x1b[s\x1b[2;1HXYZ");
assert.equal(log, "abc\x1b[H\x1b[s\x1b[2;1HXYZ");
});
test("mode controls between home and erase are kept without preserving clear controls", () => {
const log = createReplaySafeTerminalLog("history\n\x1b[H\x1b[?25l\x1b[Jnew\n");
assert.equal(log, "history\n\r\n\x1b[?25lnew\n");
});
test("erase-display backward controls are dropped from replay data", () => {
const log = createReplaySafeTerminalLog("old\n\x1b[2Jnew\x1b[1Jafter\n");
assert.equal(log, "old\n\r\nnew\r\n\r\nafter\n");
assert.equal(log.includes("\x1b[1J"), false);
});
test("scrollback-only clears protect replay history", () => {
const log = createReplaySafeTerminalLog("history\n\x1b[3J\x1b[Hoverwrite\n");
assert.equal(log, "history\n\r\noverwrite\n");
assert.equal(log.includes("\x1b[3J"), false);
assert.equal(log.includes("\x1b[H"), false);
});
test("terminal control strings are stripped from replay data", () => {
const log = createReplaySafeTerminalLog(
"before\x1b]52;c;secret\x07mid\x1b]7;file://host/path\x1b\\"
+ "dcs\x1bP1$rpayload\x1b\\apc\x1b_payload\x1b\\pm\x1b^payload\x1b\\"
+ "sos\x1bXpayload\x1b\\c1sos\x98hidden\x9cafter",
);
assert.equal(log, "beforemiddcsapcpmsosc1sosafter");
assert.equal(log.includes("\x1b"), false);
assert.equal(log.includes("secret"), false);
assert.equal(log.includes("payload"), false);
assert.equal(log.includes("hidden"), false);
});
test("split terminal control strings are stripped before truncation", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const hiddenPayload = "secret".repeat(200_000);
let captured = "";
captured += sanitizer.append("before\x1b]52;c;");
captured = captured.slice(-1_000_000);
captured += sanitizer.append(hiddenPayload);
captured = captured.slice(-1_000_000);
captured += sanitizer.append("\x07after");
captured = captured.slice(-1_000_000);
captured += sanitizer.finish();
assert.equal(captured, "beforeafter");
assert.equal(captured.includes("secret"), false);
});
test("overlong pending csi data is discarded until the sequence ends", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const parameters = "1;".repeat(3000);
const log = sanitizer.append(`before\x1b[${parameters}`)
+ sanitizer.append(parameters)
+ sanitizer.append("mafter")
+ sanitizer.finish();
assert.equal(log, "beforeafter");
assert.equal(log.includes(parameters.slice(0, 32)), false);
});
test("pending cursor-home lookahead controls are bounded before clear", () => {
const controls = "\x1b[31m".repeat(900);
const log = createReplaySafeTerminalLog(`old\n\x1b[H${controls}\x1b[2Jnew\n`);
assert.equal(log, "old\n\r\nnew\n");
assert.equal(log.includes("\x1b[31m"), false);
});
test("alternate-screen entry protects preserved replay history", () => {
const log = createReplaySafeTerminalLog(
"before\n\x1b[?1049h\x1b[Hvim screen\n\x1b[?1049lafter\n"
+ "\x1b[?47h\x1b[10;5Htop screen\n\x1b[?47l"
+ "\x1b[?25lcursor hidden\n",
);
assert.equal(log, "before\n\r\nvim screen\nafter\n\r\ntop screen\n\x1b[?25lcursor hidden\n");
assert.equal(log.includes("\x1b[?1049h"), false);
assert.equal(log.includes("\x1b[?1049l"), false);
assert.equal(log.includes("\x1b[?47h"), false);
assert.equal(log.includes("\x1b[?47l"), false);
assert.equal(log.includes("\x1b[H"), false);
assert.equal(log.includes("\x1b[10;5H"), false);
assert.equal(log.includes("\x1b[?25l"), true);
});
test("dec cursor save mode is not treated as alternate screen", () => {
const log = createReplaySafeTerminalLog("before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
assert.equal(log, "before\n\x1b[?1048h\x1b[?1048l\x1b[10;5Hpositioned\n");
});
test("cursor save and restore controls are stripped around protected clears", () => {
const log = createReplaySafeTerminalLog("old\n\x1b[s\x1b7\x1b[2J\x1b[uafter\x1b8done\n");
assert.equal(log, "old\n\x1b[s\x1b7\r\n\r\nafterdone\n");
assert.equal(log.includes("\x1b[s"), true);
assert.equal(log.includes("\x1b[u"), false);
assert.equal(log.includes("\x1b7"), true);
assert.equal(log.includes("\x1b8"), false);
});
test("terminal reset controls are dropped from replay data", () => {
const log = createReplaySafeTerminalLog("before\x1bcafter\n");
assert.equal(log, "before\r\n\r\nafter\n");
assert.equal(log.includes("\x1bc"), false);
});
test("split terminal reset controls are dropped from replay data", () => {
const sanitizer = createReplaySafeTerminalLogSanitizer();
const log = sanitizer.append("before\x1b")
+ sanitizer.append("cafter\n")
+ sanitizer.finish();
assert.equal(log, "before\r\n\r\nafter\n");
assert.equal(log.includes("\x1bc"), false);
});
test("non-clear cursor and color controls are preserved", () => {
const input = "\x1b[H\x1b[31mred\x1b[0m\n";
assert.equal(createReplaySafeTerminalLog(input), input);
});

View File

@@ -0,0 +1,427 @@
type CsiSequence = {
raw: string;
end: number;
final: string;
params: string;
};
const ESC = "\x1b";
const BEL = "\x07";
const ST = "\x9c";
const CSI = "\x9b";
const MAX_PENDING_ESCAPE_CHARS = 4096;
type ControlStringMode = "osc" | "string";
export interface ReplaySafeTerminalLogSanitizer {
append(input: string): string;
finish(): string;
}
const isCsiFinal = (ch: string): boolean => ch >= "@" && ch <= "~";
const readCsiSequence = (input: string, index: number): CsiSequence | null => {
const isEscCsi = input[index] === ESC && input[index + 1] === "[";
const isC1Csi = input[index] === CSI;
if (!isEscCsi && !isC1Csi) return null;
const paramsStart = isEscCsi ? index + 2 : index + 1;
for (let i = paramsStart; i < input.length; i += 1) {
if (isCsiFinal(input[i])) {
return {
raw: input.slice(index, i + 1),
end: i + 1,
final: input[i],
params: input.slice(paramsStart, i),
};
}
}
return null;
};
const startsCsiSequence = (input: string, index: number): boolean =>
(input[index] === ESC && input[index + 1] === "[") || input[index] === CSI;
const isEscControlStringIntroducer = (ch: string): boolean =>
ch === "]" || ch === "P" || ch === "_" || ch === "^" || ch === "X";
const isC1ControlStringIntroducer = (ch: string): boolean =>
ch === "\x90" || ch === "\x98" || ch === "\x9d" || ch === "\x9e" || ch === "\x9f";
const getControlStringStart = (
input: string,
index: number,
): { mode: ControlStringMode; dataStart: number } | null => {
const ch = input[index];
if (ch === ESC) {
const introducer = input[index + 1];
if (!isEscControlStringIntroducer(introducer)) return null;
return {
mode: introducer === "]" ? "osc" : "string",
dataStart: index + 2,
};
}
if (isC1ControlStringIntroducer(ch)) {
return {
mode: ch === "\x9d" ? "osc" : "string",
dataStart: index + 1,
};
}
return null;
};
const parseCsiParams = (params: string): Array<number | undefined> => {
const parameterBytes = params.replace(/[?><=]/g, "").replace(/[ -/]+$/g, "");
if (!parameterBytes) return [];
return parameterBytes.split(";").map((part) => {
if (!part) return undefined;
const n = Number.parseInt(part, 10);
return Number.isFinite(n) ? n : undefined;
});
};
const normalizedPosition = (value: number | undefined): number => Math.max(1, value ?? 1);
const isCursorHomeSequence = (sequence: CsiSequence): boolean => {
if (sequence.final !== "H" && sequence.final !== "f") return false;
const values = parseCsiParams(sequence.params);
return normalizedPosition(values[0]) === 1 && normalizedPosition(values[1]) === 1;
};
const isCursorMovementSequence = (sequence: CsiSequence): boolean =>
sequence.final === "A"
|| sequence.final === "B"
|| sequence.final === "C"
|| sequence.final === "D"
|| sequence.final === "E"
|| sequence.final === "F"
|| sequence.final === "H"
|| sequence.final === "f"
|| sequence.final === "G"
|| sequence.final === "`"
|| sequence.final === "d"
|| sequence.final === "a"
|| sequence.final === "e";
const isCursorStateSequence = (sequence: CsiSequence): boolean =>
sequence.final === "s"
|| sequence.final === "u"
|| (
(sequence.final === "h" || sequence.final === "l")
&& sequence.params.includes("?")
&& parseCsiParams(sequence.params).includes(1048)
);
const isUnsafeCursorReplaySequence = (sequence: CsiSequence): boolean =>
isCursorMovementSequence(sequence) || isCursorStateSequence(sequence);
const getEraseDisplayMode = (sequence: CsiSequence): number | null => {
if (sequence.final !== "J") return null;
const values = parseCsiParams(sequence.params);
return values[0] ?? 0;
};
const isEraseSequence = (sequence: CsiSequence): boolean =>
sequence.final === "J" || sequence.final === "K" || sequence.final === "X";
const isSafePendingAfterCursorHomeSequence = (sequence: CsiSequence): boolean =>
!isUnsafeCursorReplaySequence(sequence) && !isEraseSequence(sequence);
const getAlternateScreenMode = (sequence: CsiSequence): "enter" | "exit" | null => {
if (sequence.final !== "h" && sequence.final !== "l") return null;
if (!sequence.params.includes("?")) return null;
const isAlternateScreen = parseCsiParams(sequence.params).some((value) =>
value === 47 || value === 1047 || value === 1049,
);
if (!isAlternateScreen) return null;
return sequence.final === "h" ? "enter" : "exit";
};
const isC1SingleCharCursorControl = (ch: string): boolean =>
ch === "\x84" || ch === "\x85" || ch === "\x8d";
const isEscSingleCharCursorControl = (ch: string): boolean =>
ch === "D" || ch === "E" || ch === "M";
class ReplaySafeTerminalLogSanitizerImpl implements ReplaySafeTerminalLogSanitizer {
private pendingInput = "";
private pendingCursorHome = "";
private pendingAfterCursorHome = "";
private replaySafePendingAfterCursorHome = "";
private pendingAfterCursorHomeOverflowed = false;
private controlStringMode: ControlStringMode | null = null;
private controlStringEscPending = false;
private discardingCsi = false;
private inClearCluster = false;
private protectingClearedHistory = false;
private hasOutput = false;
private lastOutputChar = "";
append(input: string): string {
let output = "";
const data = this.pendingInput + input;
this.pendingInput = "";
const appendOutput = (next: string) => {
if (!next) return;
output += next;
this.hasOutput = true;
this.lastOutputChar = next[next.length - 1];
};
const flushPendingCursorHome = () => {
if (!this.pendingCursorHome) return;
if (this.protectingClearedHistory) {
appendOutput(this.replaySafePendingAfterCursorHome);
} else {
appendOutput(this.pendingCursorHome);
appendOutput(this.pendingAfterCursorHome);
}
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
};
const emitClearSeparator = (preservePendingControls: boolean) => {
const preservedControls = preservePendingControls ? this.replaySafePendingAfterCursorHome : "";
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
if (!this.inClearCluster && this.hasOutput) {
appendOutput(/[\r\n]$/.test(this.lastOutputChar) ? "\r\n" : "\r\n\r\n");
}
appendOutput(preservedControls);
this.inClearCluster = true;
this.protectingClearedHistory = true;
};
for (let i = 0; i < data.length;) {
if (this.discardingCsi) {
i = this.consumeDiscardedCsi(data, i);
continue;
}
if (this.controlStringMode) {
i = this.consumeControlString(data, i);
continue;
}
const controlStringStart = getControlStringStart(data, i);
if (controlStringStart) {
this.controlStringMode = controlStringStart.mode;
this.controlStringEscPending = false;
i = controlStringStart.dataStart;
continue;
}
const sequence = readCsiSequence(data, i);
if (!sequence && startsCsiSequence(data, i)) {
this.setPendingEscapeInput(data.slice(i));
break;
}
if (sequence) {
const alternateScreenMode = getAlternateScreenMode(sequence);
if (alternateScreenMode) {
if (alternateScreenMode === "enter") {
emitClearSeparator(false);
}
i = sequence.end;
continue;
}
if (isCursorHomeSequence(sequence)) {
if (!this.inClearCluster) {
if (!this.pendingCursorHome) {
flushPendingCursorHome();
}
this.pendingCursorHome = sequence.raw;
}
i = sequence.end;
continue;
}
if (this.protectingClearedHistory && isUnsafeCursorReplaySequence(sequence)) {
i = sequence.end;
continue;
}
const eraseMode = getEraseDisplayMode(sequence);
if (eraseMode !== null) {
if (eraseMode === 3) {
emitClearSeparator(false);
} else if (eraseMode === 1) {
emitClearSeparator(false);
} else if (eraseMode === 2 || (eraseMode === 0 && this.pendingCursorHome)) {
emitClearSeparator(true);
} else {
flushPendingCursorHome();
appendOutput(sequence.raw);
this.inClearCluster = false;
}
i = sequence.end;
continue;
}
if (this.pendingCursorHome) {
this.appendPendingAfterCursorHome(sequence);
i = sequence.end;
continue;
}
const preserveClearCluster = this.inClearCluster;
flushPendingCursorHome();
appendOutput(sequence.raw);
this.inClearCluster = preserveClearCluster;
i = sequence.end;
continue;
}
if (this.protectingClearedHistory && isC1SingleCharCursorControl(data[i])) {
i += 1;
continue;
}
if (data[i] === ESC) {
if (i + 1 >= data.length) {
this.setPendingEscapeInput(data.slice(i));
break;
}
if (data[i + 1] === "c") {
emitClearSeparator(false);
i += 2;
continue;
}
if (this.protectingClearedHistory && (data[i + 1] === "7" || data[i + 1] === "8")) {
i += 2;
continue;
}
if (this.protectingClearedHistory && isEscSingleCharCursorControl(data[i + 1])) {
i += 2;
continue;
}
}
flushPendingCursorHome();
appendOutput(data[i]);
this.inClearCluster = false;
i += 1;
}
return output;
}
finish(): string {
this.pendingInput = "";
this.controlStringMode = null;
this.controlStringEscPending = false;
this.discardingCsi = false;
let output = "";
if (this.pendingCursorHome) {
output = this.pendingCursorHome + this.pendingAfterCursorHome;
this.hasOutput = true;
this.lastOutputChar = output[output.length - 1];
this.pendingCursorHome = "";
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = false;
}
return output;
}
private appendPendingAfterCursorHome(sequence: CsiSequence): void {
if (this.pendingAfterCursorHomeOverflowed) return;
const nextLength = this.pendingAfterCursorHome.length + sequence.raw.length;
if (nextLength > MAX_PENDING_ESCAPE_CHARS) {
this.pendingAfterCursorHome = "";
this.replaySafePendingAfterCursorHome = "";
this.pendingAfterCursorHomeOverflowed = true;
return;
}
this.pendingAfterCursorHome += sequence.raw;
if (isSafePendingAfterCursorHomeSequence(sequence)) {
this.replaySafePendingAfterCursorHome += sequence.raw;
}
}
private setPendingEscapeInput(input: string): void {
if (input.length > MAX_PENDING_ESCAPE_CHARS) {
this.pendingInput = "";
this.discardingCsi = true;
return;
}
this.pendingInput = input;
}
private consumeDiscardedCsi(input: string, index: number): number {
for (let i = index; i < input.length; i += 1) {
if (isCsiFinal(input[i])) {
this.discardingCsi = false;
return i + 1;
}
}
return input.length;
}
private consumeControlString(input: string, index: number): number {
let i = index;
if (this.controlStringEscPending) {
if (input[i] === "\\") {
this.controlStringMode = null;
this.controlStringEscPending = false;
return i + 1;
}
this.controlStringEscPending = false;
}
for (; i < input.length; i += 1) {
if (this.controlStringMode === "osc" && input[i] === BEL) {
this.controlStringMode = null;
return i + 1;
}
if (input[i] === ST) {
this.controlStringMode = null;
return i + 1;
}
if (input[i] === ESC) {
if (i + 1 >= input.length) {
this.controlStringEscPending = true;
return input.length;
}
if (input[i + 1] === "\\") {
this.controlStringMode = null;
return i + 2;
}
}
}
return input.length;
}
}
export const createReplaySafeTerminalLogSanitizer = (): ReplaySafeTerminalLogSanitizer =>
new ReplaySafeTerminalLogSanitizerImpl();
/**
* Convert terminal output into a form that can be replayed in LogView without
* allowing shell `clear` / ED2 / ED3 controls to wipe earlier log history.
*/
export function createReplaySafeTerminalLog(input: string): string {
const sanitizer = createReplaySafeTerminalLogSanitizer();
return sanitizer.append(input) + sanitizer.finish();
}

View File

@@ -2,6 +2,8 @@ import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
import { createPromptLineBreakState } from "./promptLineBreak";
import { pasteTextIntoTerminal } from "./terminalUserPaste";
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
@@ -22,6 +24,487 @@ test("getMissingChainHostIds reports unresolved jump hosts", () => {
);
});
test("startSerial captures direct connected banner in terminal log data", async () => {
const capturedLogData: string[] = [];
const writtenData: string[] = [];
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "serial-host",
label: "Serial",
hostname: "COM3",
username: "",
protocol: "serial",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
serialConfig: {
path: "COM3",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
},
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
onTerminalLogData: (data: string) => capturedLogData.push(data),
};
const term = {
cols: 120,
rows: 32,
write: (data: string, callback?: () => void) => {
writtenData.push(data);
callback?.();
},
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startSerial(term as never);
const banner = "[Connected to COM3 at 9600 baud]";
assert.deepEqual(writtenData, [`${banner}\r\n`]);
assert.deepEqual(capturedLogData, [`${banner}\r\n`]);
});
test("local session captures paste cleanup writes in terminal log data", async () => {
const capturedLogData: string[] = [];
const writes: string[] = [];
let onData: ((data: string) => void) | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "local-host",
label: "Local",
hostname: "local",
username: "",
protocol: "local",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
onTerminalLogData: (data: string) => capturedLogData.push(data),
};
const term = {
cols: 20,
rows: 4,
paste: noop,
write: (data: string, callback?: () => void) => {
writes.push(data);
callback?.();
},
writeln: noop,
scrollToBottom: noop,
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, { scrollOnPaste: false });
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.notEqual(onData, null);
onData?.("\x1b[7mline 3 with enough content\x1b[27m");
assert.deepEqual(writes, ["line 3 with enough content", "\x1b[K"]);
assert.deepEqual(capturedLogData, ["line 3 with enough content", "\x1b[K"]);
});
test("session data waits for prior terminal writes before evaluating prompt line breaks", async () => {
const writes: string[] = [];
const writeCallbacks: Array<() => void> = [];
let onData: ((data: string) => void) | null = null;
let cursorX = 0;
let lineText = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const promptState = createPromptLineBreakState();
promptState.lastPromptText = "$ ";
promptState.pendingCommand = true;
const ctx = {
host: {
id: "local-host",
label: "Local",
hostname: "local",
username: "",
protocol: "local",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: { forcePromptNewLine: true },
terminalBackend,
promptLineBreakStateRef: { current: promptState },
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
get buffer() {
return {
active: {
get cursorX() {
return cursorX;
},
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
};
},
write: (data: string, callback?: () => void) => {
writes.push(data);
if (callback) writeCallbacks.push(callback);
},
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.notEqual(onData, null);
onData?.("hello");
onData?.("$ ");
assert.deepEqual(writes, ["hello"]);
cursorX = 5;
lineText = "hello";
writeCallbacks.shift()?.();
assert.deepEqual(writes, ["hello", "\r\n$ "]);
});
test("prompt line break display insertion does not mutate captured session log data", async () => {
const writes: string[] = [];
const capturedLogData: string[] = [];
const writeCallbacks: Array<() => void> = [];
let onData: ((data: string) => void) | null = null;
let cursorX = 0;
let lineText = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const promptState = createPromptLineBreakState();
promptState.lastPromptText = "$ ";
promptState.pendingCommand = true;
const ctx = {
host: {
id: "local-host",
label: "Local",
hostname: "local",
username: "",
protocol: "local",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: { forcePromptNewLine: true },
terminalBackend,
promptLineBreakStateRef: { current: promptState },
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
onTerminalLogData: (data: string) => capturedLogData.push(data),
};
const term = {
get buffer() {
return {
active: {
get cursorX() {
return cursorX;
},
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
};
},
write: (data: string, callback?: () => void) => {
writes.push(data);
if (callback) writeCallbacks.push(callback);
},
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.notEqual(onData, null);
onData?.("hello");
onData?.("$ ");
cursorX = 5;
lineText = "hello";
writeCallbacks.shift()?.();
assert.deepEqual(writes, ["hello", "\r\n$ "]);
assert.deepEqual(capturedLogData, ["hello", "$ "]);
});
test("local session exit text waits for pending terminal output writes", async () => {
const writes: string[] = [];
const writeCallbacks: Array<() => void> = [];
let onData: ((data: string) => void) | null = null;
let onExit: ((evt: { reason?: "closed" }) => void) | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
onSessionExit: (_id: string, cb: (evt: { reason?: "closed" }) => void) => {
onExit = cb;
return noop;
},
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "local-host",
label: "Local",
hostname: "local",
username: "",
protocol: "local",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 20,
rows: 4,
write: (data: string, callback?: () => void) => {
writes.push(data);
if (callback) writeCallbacks.push(callback);
},
writeln: (data: string) => {
writes.push(`${data}\r\n`);
},
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
assert.notEqual(onData, null);
assert.notEqual(onExit, null);
onData?.("partial output");
onExit?.({ reason: "closed" });
assert.deepEqual(writes, ["partial output"]);
writeCallbacks.shift()?.();
assert.deepEqual(writes, ["partial output", "\r\n[session closed]\r\n"]);
});
test("startSSH allows jump hosts that use reference key files with unavailable saved passphrases", async () => {
let capturedOptions: Record<string, unknown> | null = null;
let error = "";

View File

@@ -21,6 +21,12 @@ import {
clearPasteResidualAfterTerminalWrite,
prepareTerminalDataForUserPasteDisplay,
} from "./terminalUserPaste";
import {
markPromptLineBreakCommandPending,
prepareTerminalDataForPromptLineBreak,
syncPromptLineBreakState,
type PromptLineBreakState,
} from "./promptLineBreak";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -138,6 +144,7 @@ export type TerminalSessionStartersContext = {
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
pendingAuthRef: RefObject<PendingAuth>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
updateStatus: (next: TerminalSession["status"]) => void;
setStatus: Dispatch<SetStateAction<TerminalSession["status"]>>;
@@ -153,6 +160,7 @@ export type TerminalSessionStartersContext = {
onSessionAttached?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalLogData?: (data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCommandExecuted?: (
command: string,
@@ -205,23 +213,97 @@ const handleTerminalOutputAutoScroll = (
term.scrollToBottom();
};
type TerminalWriteQueue = {
writing: boolean;
pending: Array<() => void>;
};
const terminalWriteQueues = new WeakMap<XTerm, TerminalWriteQueue>();
const scheduleNextTerminalWrite = (term: XTerm, queue: TerminalWriteQueue) => {
const next = queue.pending.shift();
if (!next) {
queue.writing = false;
terminalWriteQueues.delete(term);
return;
}
queue.writing = true;
next();
};
const enqueueTerminalWrite = (
term: XTerm,
write: (done: () => void) => void,
) => {
let queue = terminalWriteQueues.get(term);
if (!queue) {
queue = { writing: false, pending: [] };
terminalWriteQueues.set(term, queue);
}
queue.pending.push(() => {
write(() => scheduleNextTerminalWrite(term, queue));
});
if (!queue.writing) {
scheduleNextTerminalWrite(term, queue);
}
};
const writeTerminalLine = (
ctx: TerminalSessionStartersContext,
term: XTerm,
data: string,
) => {
enqueueTerminalWrite(term, (done) => {
const lineData = `${data}\r\n`;
ctx.onTerminalLogData?.(lineData);
term.write(lineData, done);
});
};
const writeSessionData = (
ctx: TerminalSessionStartersContext,
term: XTerm,
data: string,
) => {
const displayData = prepareTerminalDataForUserPasteDisplay(term, data);
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
if (!shouldScrollOnTerminalOutput(settings)) {
term.write(displayData, () => {
clearPasteResidualAfterTerminalWrite(term);
});
return;
}
enqueueTerminalWrite(term, (done) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const forcePromptNewLine = settings?.forcePromptNewLine ?? true;
if (!forcePromptNewLine && ctx.promptLineBreakStateRef?.current) {
ctx.promptLineBreakStateRef.current.pendingCommand = false;
ctx.promptLineBreakStateRef.current.suppressNextPromptCache = false;
}
const pasteDisplayData = prepareTerminalDataForUserPasteDisplay(term, data);
const displayData = prepareTerminalDataForPromptLineBreak(
term,
pasteDisplayData,
ctx.promptLineBreakStateRef?.current,
forcePromptNewLine,
);
ctx.onTerminalLogData?.(pasteDisplayData);
const clearPasteResidualAndCapture = () => {
const cleanupData = clearPasteResidualAfterTerminalWrite(term);
if (cleanupData) {
ctx.onTerminalLogData?.(cleanupData);
}
};
const syncPrompt = () => {
if (forcePromptNewLine) {
syncPromptLineBreakState(term, ctx.promptLineBreakStateRef?.current);
}
};
const afterWrite = () => {
clearPasteResidualAndCapture();
syncPrompt();
if (shouldScrollOnTerminalOutput(settings)) {
handleTerminalOutputAutoScroll(ctx, term);
}
done();
};
term.write(displayData, () => {
clearPasteResidualAfterTerminalWrite(term);
handleTerminalOutputAutoScroll(ctx, term);
term.write(displayData, afterWrite);
});
};
@@ -271,7 +353,8 @@ const attachSessionToTerminal = (
if (evt.error) {
ctx.setError(evt.error);
}
term.writeln(opts?.onExitMessage?.(evt) ?? "\r\n[session closed]");
const exitMessage = opts?.onExitMessage?.(evt) ?? "\r\n[session closed]";
writeTerminalLine(ctx, term, exitMessage);
if (ctx.onTerminalDataCapture && ctx.serializeAddonRef.current) {
try {
@@ -311,6 +394,9 @@ const scheduleStartupCommand = (
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
automated: true,
});
if (!ctx.noAutoRun) {
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
}
onSettled?.();
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -392,7 +478,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const startSSH = async (term: XTerm) => {
if (!ctx.terminalBackend.backendAvailable()) {
ctx.setError("Native SSH bridge unavailable. Launch via Electron app.");
term.writeln(
writeTerminalLine(
ctx,
term,
"\r\n[netcatty SSH bridge unavailable. Please run the desktop build to connect.]",
);
ctx.updateStatus("disconnected");
@@ -412,7 +500,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -455,7 +543,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -475,7 +563,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (unresolvedJumpProxyHost) {
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -563,7 +651,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -582,7 +670,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -823,7 +911,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setStatus("connecting");
} else {
ctx.setError(message);
term.writeln(`\r\n[Failed to start SSH: ${message}]`);
writeTerminalLine(ctx, term, `\r\n[Failed to start SSH: ${message}]`);
ctx.updateStatus("disconnected");
}
@@ -835,7 +923,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const startTelnet = async (term: XTerm) => {
if (!ctx.terminalBackend.telnetAvailable()) {
ctx.setError("Telnet bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Telnet bridge unavailable. Please run the desktop build.]");
writeTerminalLine(ctx, term, "\r\n[Telnet bridge unavailable. Please run the desktop build.]");
ctx.updateStatus("disconnected");
return;
}
@@ -843,7 +931,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -851,7 +939,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -887,7 +975,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
@@ -928,24 +1016,24 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
telnetSessionId = id;
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExit: cleanupTelnetStartupWait,
});
const disposeTelnetExit = ctx.disposeExitRef.current;
ctx.disposeExitRef.current = () => {
cleanupTelnetStartupWait();
disposeTelnetExit?.();
};
if (waitsForAutoLogin) {
return;
}
} catch (err) {
cleanupTelnetStartupWait();
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[Telnet session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
onExit: cleanupTelnetStartupWait,
});
const disposeTelnetExit = ctx.disposeExitRef.current;
ctx.disposeExitRef.current = () => {
cleanupTelnetStartupWait();
disposeTelnetExit?.();
};
if (waitsForAutoLogin) {
return;
}
} catch (err) {
cleanupTelnetStartupWait();
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start Telnet: ${message}]`);
writeTerminalLine(ctx, term, `\r\n[Failed to start Telnet: ${message}]`);
ctx.updateStatus("disconnected");
}
};
@@ -953,7 +1041,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const startMosh = async (term: XTerm) => {
if (!ctx.terminalBackend.moshAvailable()) {
ctx.setError("Mosh bridge unavailable. Please run the desktop build.");
term.writeln("\r\n[Mosh bridge unavailable. Please run the desktop build.]");
writeTerminalLine(ctx, term, "\r\n[Mosh bridge unavailable. Please run the desktop build.]");
ctx.updateStatus("disconnected");
return;
}
@@ -961,7 +1049,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
try {
const stopMosh = (message: string) => {
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
};
@@ -1074,7 +1162,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start Mosh: ${message}]`);
writeTerminalLine(ctx, term, `\r\n[Failed to start Mosh: ${message}]`);
ctx.updateStatus("disconnected");
}
};
@@ -1082,7 +1170,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const startLocal = async (term: XTerm) => {
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
term.writeln(
writeTerminalLine(
ctx,
term,
"\r\n[Local shell bridge unavailable. Please run the desktop build to spawn a local terminal.]",
);
ctx.updateStatus("disconnected");
@@ -1133,9 +1223,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.disposeExitRef.current = ctx.terminalBackend.onSessionExit(id, (evt) => {
ctx.updateStatus("disconnected");
term.writeln(
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
);
const exitMessage = `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
writeTerminalLine(ctx, term, exitMessage);
logger.info("[Terminal] Session exit, capturing data", {
sessionId: ctx.sessionId,
@@ -1161,7 +1250,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to start local shell: ${message}]`);
writeTerminalLine(ctx, term, `\r\n[Failed to start local shell: ${message}]`);
ctx.updateStatus("disconnected");
}
};
@@ -1170,7 +1259,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const startSerial = async (term: XTerm) => {
if (!ctx.serialConfig) {
ctx.setError("No serial configuration provided");
term.writeln("\r\n[Error: No serial configuration provided]");
writeTerminalLine(ctx, term, "\r\n[Error: No serial configuration provided]");
ctx.updateStatus("disconnected");
return;
}
@@ -1197,7 +1286,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Update status right away since serial ports don't require handshake
ctx.updateStatus("connected");
ctx.setProgressValue(100);
term.writeln(`[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
@@ -1208,7 +1297,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
term.writeln(`\r\n[Failed to connect to serial port: ${message}]`);
writeTerminalLine(ctx, term, `\r\n[Failed to connect to serial port: ${message}]`);
ctx.updateStatus("disconnected");
}
};

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import { createPromptLineBreakState } from "./promptLineBreak";
test("command execution arms prompt line break even without command history callback", () => {
const promptState = createPromptLineBreakState();
const commandBufferRef = { current: "echo ok" };
recordTerminalCommandExecution("echo ok", {
host: {
id: "host-1",
label: "Host",
hostname: "example.test",
username: "alice",
},
sessionId: "session-1",
commandBufferRef,
promptLineBreakStateRef: { current: promptState },
});
assert.equal(commandBufferRef.current, "");
assert.equal(promptState.pendingCommand, true);
});

View File

@@ -46,8 +46,13 @@ import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
pasteTextIntoTerminal,
shouldBroadcastTerminalUserInput,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
import {
type PromptLineBreakState,
} from "./promptLineBreak";
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
import type {
Host,
KeyBinding,
@@ -108,12 +113,14 @@ export type CreateXTermRuntimeContext = {
sessionId: string,
) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
setIsSearchOpen: Dispatch<SetStateAction<boolean>>;
// Serial-specific options
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
onTerminalLogData?: (data: string) => void;
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
@@ -410,6 +417,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
const broadcastUserPasteData = (data: string) => {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
return true;
}
return false;
};
const scrollToBottomAfterInput = (data: string) => {
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
term.scrollToBottom();
@@ -499,10 +513,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// where each \n executes an intermediate command (#814 P2).
ctx.onAutocompleteInput?.(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
if (!snippet.noAutoRun) {
recordTerminalCommandExecution(snippet.command, ctx);
}
return false;
}
@@ -536,6 +548,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (id) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
});
@@ -547,6 +560,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (selection && id) {
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
break;
@@ -599,6 +613,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (text && ctx.sessionRef.current) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
} catch (err) {
@@ -614,6 +629,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
fitAddon.fit();
term.focus();
const writeLocalTerminalData = (nextData: string) => {
ctx.onTerminalLogData?.(nextData);
term.write(nextData);
};
term.onData((data) => {
const id = ctx.sessionRef.current;
if (id) {
@@ -623,7 +643,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
writeToSession: (nextData) => ctx.terminalBackend.writeToSession(id, nextData),
writeToTerminal: (nextData) => term.write(nextData),
writeToTerminal: writeLocalTerminalData,
});
} else {
// Character mode (default): send immediately
@@ -637,21 +657,25 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Local echo for serial connections only when explicitly enabled
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
if (data === "\r") {
term.write("\r\n");
writeLocalTerminalData("\r\n");
} else if (data === "\x7f" || data === "\b") {
term.write("\b \b");
writeLocalTerminalData("\b \b");
} else if (data === "\x03") {
term.write("^C");
writeLocalTerminalData("^C");
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
term.write(data);
writeLocalTerminalData(data);
}
}
}
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
// Use remapped data so broadcast peers also receive the correct byte
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
const onBroadcastInput = ctx.onBroadcastInputRef.current;
// Use remapped data so broadcast peers also receive the correct byte
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
if (shouldBroadcastTerminalUserInput(term, broadcastData, {
isBroadcastEnabled: ctx.isBroadcastEnabledRef.current,
hasBroadcastInputHandler: !!onBroadcastInput,
})) {
onBroadcastInput?.(broadcastData, ctx.sessionId);
}
if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
@@ -661,11 +685,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Notify autocomplete of input
ctx.onAutocompleteInput?.(data);
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
if (ctx.statusRef.current === "connected") {
if (data === "\r" || data === "\n") {
const cmd = ctx.commandBufferRef.current.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx);
} else if (data === "\x7f" || data === "\b") {
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
} else if (data === "\x03") {

View File

@@ -0,0 +1,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createPromptLineBreakState,
insertPromptLineBreakBeforePrompt,
prepareTerminalDataForPromptLineBreak,
syncPromptLineBreakState,
} from "./promptLineBreak";
function createFakeTerm(lineText = "", cursorX = lineText.length) {
return {
buffer: {
active: {
cursorX,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("does not insert before prompt-like suffixes in a larger output chunk", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("hello$ ", "$ ", 0),
"hello$ ",
);
});
test("inserts at the start of a prompt chunk when previous output left the cursor mid-line", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("$ ", "$ ", 5),
"\r\n$ ",
);
});
test("does not insert when the output already ends with a line break", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("hello\r\n$ ", "$ ", 0),
"hello\r\n$ ",
);
});
test("keeps prompt ANSI styling on the prompt side of the inserted line break", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("\x1b[32m$ \x1b[0m", "$ ", 5),
"\r\n\x1b[32m$ \x1b[0m",
);
});
test("does not insert for non-prompt output", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("hello> ", "$ ", 0),
"hello> ",
);
});
test("does not insert for output chunks that only end with the cached prompt text", () => {
assert.equal(
insertPromptLineBreakBeforePrompt("total $ ", "$ ", 0),
"total $ ",
);
});
test("does not refresh cached prompt from output that only ends with the prompt text", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "$ ";
state.pendingCommand = true;
assert.equal(
prepareTerminalDataForPromptLineBreak(
createFakeTerm("", 0) as never,
"total $ ",
state,
true,
),
"total $ ",
);
assert.equal(state.suppressNextPromptCache, true);
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
assert.equal(state.lastPromptText, "$ ");
assert.equal(state.pendingCommand, false);
assert.equal(state.suppressNextPromptCache, false);
});
test("refreshes cached prompt when a changed prompt arrives after a line break in the same chunk", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "old$ ";
state.pendingCommand = true;
const termBeforeWrite = createFakeTerm("old$ cd /tmp", 12);
assert.equal(
prepareTerminalDataForPromptLineBreak(
termBeforeWrite as never,
"\r\nnew$ ",
state,
true,
),
"\r\nnew$ ",
);
assert.equal(state.suppressNextPromptCache, false);
syncPromptLineBreakState(createFakeTerm("new$ ") as never, state);
assert.equal(state.lastPromptText, "new$ ");
assert.equal(state.pendingCommand, false);
});
test("caches the first valid prompt even when a command is already pending", () => {
const state = createPromptLineBreakState();
state.pendingCommand = true;
syncPromptLineBreakState(createFakeTerm("$ ") as never, state);
assert.equal(state.lastPromptText, "$ ");
assert.equal(state.pendingCommand, false);
assert.equal(state.suppressNextPromptCache, false);
});
test("does not refresh cached prompt from an unchanged mid-line write without a line reset", () => {
const state = createPromptLineBreakState();
state.lastPromptText = "old$ ";
state.pendingCommand = true;
const termBeforeWrite = createFakeTerm("old$ run", 8);
assert.equal(
prepareTerminalDataForPromptLineBreak(
termBeforeWrite as never,
"outputnew$ ",
state,
true,
),
"outputnew$ ",
);
assert.equal(state.suppressNextPromptCache, true);
syncPromptLineBreakState(createFakeTerm("outputnew$ ") as never, state);
assert.equal(state.lastPromptText, "old$ ");
assert.equal(state.pendingCommand, false);
assert.equal(state.suppressNextPromptCache, false);
});

View File

@@ -0,0 +1,170 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import { detectPrompt } from "../autocomplete/promptDetector";
export type PromptLineBreakState = {
lastPromptText: string;
pendingCommand: boolean;
suppressNextPromptCache: boolean;
};
type VisibleTextMap = {
text: string;
rawStartByTextIndex: number[];
};
const ESC = "\x1b";
const BEL = "\x07";
const isCsiFinalByte = (char: string): boolean => {
const code = char.charCodeAt(0);
return code >= 0x40 && code <= 0x7e;
};
const mapVisibleText = (data: string): VisibleTextMap => {
let text = "";
const rawStartByTextIndex: number[] = [];
let nextVisibleSegmentStart = 0;
const appendVisible = (index: number, char: string) => {
rawStartByTextIndex.push(nextVisibleSegmentStart);
text += char;
nextVisibleSegmentStart = index + char.length;
};
for (let index = 0; index < data.length; index += 1) {
const char = data[index];
if (char !== ESC) {
appendVisible(index, char);
continue;
}
const nextChar = data[index + 1];
if (nextChar === "[") {
index += 2;
while (index < data.length && !isCsiFinalByte(data[index])) {
index += 1;
}
continue;
}
if (nextChar === "]") {
index += 2;
while (index < data.length) {
if (data[index] === BEL) break;
if (data[index] === ESC && data[index + 1] === "\\") {
index += 1;
break;
}
index += 1;
}
continue;
}
if (nextChar) {
index += 1;
}
}
return { text, rawStartByTextIndex };
};
const endsWithLineBreak = (text: string): boolean => {
const last = text[text.length - 1];
return last === "\n" || last === "\r";
};
const containsLineReset = (text: string): boolean =>
text.includes("\n") || text.includes("\r");
const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean => {
const mapped = mapVisibleText(data);
if (!mapped.text.endsWith(promptText)) return false;
const promptTextStart = mapped.text.length - promptText.length;
const prefixText = mapped.text.slice(0, promptTextStart);
return prefixText.length > 0 && !endsWithLineBreak(prefixText);
};
const getCursorX = (term: XTerm): number => {
try {
return term.buffer.active.cursorX;
} catch {
return 0;
}
};
export function createPromptLineBreakState(): PromptLineBreakState {
return {
lastPromptText: "",
pendingCommand: false,
suppressNextPromptCache: false,
};
}
export function markPromptLineBreakCommandPending(
stateRef?: RefObject<PromptLineBreakState>,
): void {
if (!stateRef?.current) return;
stateRef.current.pendingCommand = true;
stateRef.current.suppressNextPromptCache = false;
}
export function insertPromptLineBreakBeforePrompt(
data: string,
promptText: string,
cursorXBeforeWrite: number,
): string {
if (!data || !promptText) return data;
const mapped = mapVisibleText(data);
if (!mapped.text.endsWith(promptText)) return data;
const promptTextStart = mapped.text.length - promptText.length;
const prefixText = mapped.text.slice(0, promptTextStart);
if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data;
if (prefixText.length > 0) return data;
const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0;
return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`;
}
export function prepareTerminalDataForPromptLineBreak(
term: XTerm,
data: string,
state: PromptLineBreakState | undefined,
enabled: boolean,
): string {
if (!enabled || !state?.pendingCommand || !state.lastPromptText) return data;
const cursorXBeforeWrite = getCursorX(term);
const nextData = insertPromptLineBreakBeforePrompt(
data,
state.lastPromptText,
cursorXBeforeWrite,
);
const visibleText = mapVisibleText(data).text;
state.suppressNextPromptCache =
nextData === data &&
(cursorXBeforeWrite > 0 ||
hasAmbiguousPromptSuffix(data, state.lastPromptText)) &&
!containsLineReset(visibleText);
return nextData;
}
export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakState): void {
if (!state) return;
const prompt = detectPrompt(term);
if (!prompt.isAtPrompt || prompt.userInput.length > 0) return;
if (state.pendingCommand && state.suppressNextPromptCache) {
state.suppressNextPromptCache = false;
state.pendingCommand = false;
return;
}
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
state.pendingCommand = false;
}

View File

@@ -0,0 +1,31 @@
import type { RefObject } from "react";
import type { Host } from "../../../types";
import {
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./promptLineBreak";
type TerminalCommandExecutionContext = {
host: Pick<Host, "id" | "label">;
sessionId: string;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
};
export const recordTerminalCommandExecution = (
command: string,
ctx: TerminalCommandExecutionContext,
) => {
const cmd = command.trim();
if (cmd) {
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef);
};

View File

@@ -5,6 +5,8 @@ import {
clearPasteResidualAfterTerminalWrite,
pasteTextIntoTerminal,
prepareTerminalDataForUserPasteDisplay,
shouldBroadcastTerminalUserInput,
shouldSuppressTerminalBroadcastForUserPaste,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
@@ -24,6 +26,131 @@ test("user paste delegates raw clipboard text to xterm paste handling", () => {
assert.deepEqual(pasted, [text]);
});
test("user paste reports prepared terminal input for broadcast targets", () => {
const pasted: string[] = [];
const broadcastData: string[] = [];
const term = {
paste: (text: string) => pasted.push(text),
scrollToBottom: () => {},
};
const text = "line one\r\nline two\nline three";
const options = {
scrollOnPaste: false,
onPasteData: (data: string) => broadcastData.push(data),
};
pasteTextIntoTerminal(term, text, options);
assert.deepEqual(pasted, [text]);
assert.deepEqual(broadcastData, ["line one\rline two\rline three"]);
});
test("user paste reports bracketed terminal input for broadcast targets when bracketed paste is active", () => {
const broadcastData: string[] = [];
const term = {
modes: { bracketedPasteMode: true },
options: { ignoreBracketedPasteMode: false },
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
onPasteData: (data: string) => broadcastData.push(data),
});
assert.deepEqual(broadcastData, ["\x1b[200~line one\rline two\x1b[201~"]);
});
test("user paste reports plain terminal input for broadcast targets when bracketed paste is ignored", () => {
const broadcastData: string[] = [];
const term = {
modes: { bracketedPasteMode: true },
options: { ignoreBracketedPasteMode: true },
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
onPasteData: (data: string) => broadcastData.push(data),
});
assert.deepEqual(broadcastData, ["line one\rline two"]);
});
test("user paste broadcast data is consumed so xterm onData does not rebroadcast it", () => {
const term = {
modes: { bracketedPasteMode: true },
options: { ignoreBracketedPasteMode: false },
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
onPasteData: () => true,
});
assert.equal(
shouldSuppressTerminalBroadcastForUserPaste(
term,
"\x1b[200~line one\rline two\x1b[201~",
),
true,
);
assert.equal(
shouldSuppressTerminalBroadcastForUserPaste(
term,
"\x1b[200~line one\rline two\x1b[201~",
),
false,
);
});
test("user paste does not suppress later broadcast when paste callback did not broadcast", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one", {
scrollOnPaste: false,
onPasteData: () => false,
});
assert.equal(shouldSuppressTerminalBroadcastForUserPaste(term, "line one"), false);
});
test("broadcast gate consumes paste state even when broadcast is disabled before onData", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one", {
scrollOnPaste: false,
onPasteData: () => true,
});
assert.equal(
shouldBroadcastTerminalUserInput(term, "line one", {
isBroadcastEnabled: false,
hasBroadcastInputHandler: true,
}),
false,
);
assert.equal(
shouldBroadcastTerminalUserInput(term, "line one", {
isBroadcastEnabled: true,
hasBroadcastInputHandler: true,
}),
true,
);
});
test("user paste preserves the existing scroll-on-paste behavior", () => {
const calls: string[] = [];
const term = {

View File

@@ -1,11 +1,20 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
Partial<Pick<XTerm, "cols" | "rows" | "write">>;
Partial<Pick<XTerm, "cols" | "rows" | "write">> & {
modes?: { bracketedPasteMode?: boolean };
options?: { ignoreBracketedPasteMode?: boolean };
};
type PasteOptions = {
scrollOnPaste?: boolean;
requestAnimationFrame?: (callback: () => void) => unknown;
onPasteData?: (data: string) => boolean | void;
};
type BroadcastUserInputOptions = {
isBroadcastEnabled?: boolean;
hasBroadcastInputHandler?: boolean;
};
type PasteDisplayState = {
@@ -22,6 +31,7 @@ type PasteInputScrollState = {
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
const pasteBroadcastStates = new WeakMap<object, PasteInputScrollState>();
const LONG_PASTE_MIN_LENGTH = 200;
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
@@ -128,6 +138,14 @@ const getPasteInputDataVariants = (text: string): string[] => {
).filter((candidate) => candidate.length > 0);
};
const getPasteInputData = (term: PasteTarget, text: string): string => {
const preparedText = preparePasteTextForXterm(text);
if (term.modes?.bracketedPasteMode && !term.options?.ignoreBracketedPasteMode) {
return `${BRACKETED_PASTE_START}${preparedText}${BRACKETED_PASTE_END}`;
}
return preparedText;
};
const isExpectedPasteEcho = (data: string, state: PasteDisplayState): boolean => {
if (state.pasteEchoFragments.length === 0) return false;
@@ -245,6 +263,21 @@ export function pasteTextIntoTerminal(
pasteInputScrollStates.delete(term);
}
if (options.onPasteData) {
const pasteData = getPasteInputData(term, text);
const didBroadcast = options.onPasteData(pasteData) === true;
if (didBroadcast) {
pasteBroadcastStates.set(term, {
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
remainingDataVariants: [pasteData],
});
} else {
pasteBroadcastStates.delete(term);
}
} else {
pasteBroadcastStates.delete(term);
}
term.paste(text);
if (!options.scrollOnPaste) return;
@@ -264,9 +297,30 @@ export function pasteTextIntoTerminal(
}
export function shouldSuppressTerminalInputScrollForUserPaste(term: object, data: string): boolean {
const state = pasteInputScrollStates.get(term);
return consumePasteInputState(pasteInputScrollStates, term, data);
}
export function shouldSuppressTerminalBroadcastForUserPaste(term: object, data: string): boolean {
return consumePasteInputState(pasteBroadcastStates, term, data);
}
export function shouldBroadcastTerminalUserInput(
term: object,
data: string,
options: BroadcastUserInputOptions,
): boolean {
const isSuppressedUserPaste = shouldSuppressTerminalBroadcastForUserPaste(term, data);
return !isSuppressedUserPaste && !!options.isBroadcastEnabled && !!options.hasBroadcastInputHandler;
}
function consumePasteInputState(
states: WeakMap<object, PasteInputScrollState>,
term: object,
data: string,
): boolean {
const state = states.get(term);
if (!isStateActive(state)) {
pasteInputScrollStates.delete(term);
states.delete(term);
return false;
}
@@ -280,7 +334,7 @@ export function shouldSuppressTerminalInputScrollForUserPaste(term: object, data
if (candidate.length > data.length) {
state.remainingDataVariants[matchingIndex] = candidate.slice(data.length);
} else {
pasteInputScrollStates.delete(term);
states.delete(term);
}
return true;
}
@@ -302,14 +356,16 @@ export function prepareTerminalDataForUserPasteDisplay(term: object, data: strin
return data;
}
export function clearPasteResidualAfterTerminalWrite(term: object): void {
export function clearPasteResidualAfterTerminalWrite(term: object): string | null {
const state = pasteDisplayStates.get(term);
if (!isStateActive(state)) return;
if (state.clearPending <= 0) return;
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return;
if (!isStateActive(state)) return null;
if (state.clearPending <= 0) return null;
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return null;
// Readline can leave stale cells to the right of the cursor after very long
// bracketed paste redraws; clear them locally without sending bytes upstream.
state.clearPending -= 1;
(term as Pick<XTerm, "write">).write("\x1b[K");
const cleanupData = "\x1b[K";
(term as Pick<XTerm, "write">).write(cleanupData);
return cleanupData;
}

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { broadcastTerminalPasteData } from "./hooks/useTerminalContextActions";
test("terminal context paste reports whether it broadcast to peers", () => {
const broadcasted: Array<{ data: string; sessionId: string }> = [];
const didBroadcast = broadcastTerminalPasteData("line one", {
sourceSessionId: "workspace-session-1",
sessionRef: { current: "backend-session-1" },
isBroadcastEnabledRef: { current: true },
onBroadcastInputRef: {
current: (data, sourceSessionId) => {
broadcasted.push({ data, sessionId: sourceSessionId });
},
},
});
assert.equal(didBroadcast, true);
assert.deepEqual(broadcasted, [{ data: "line one", sessionId: "workspace-session-1" }]);
});
test("terminal context paste reports false when broadcast is disabled", () => {
const didBroadcast = broadcastTerminalPasteData("line one", {
sourceSessionId: "workspace-session-1",
sessionRef: { current: "session-1" },
isBroadcastEnabledRef: { current: false },
onBroadcastInputRef: {
current: () => {
throw new Error("broadcast should not run");
},
},
});
assert.equal(didBroadcast, false);
});

View File

@@ -32,6 +32,7 @@ export const terminalLayerAreEqual = (
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities

View File

@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getTerminalPaneSnapshot, HIDDEN_TERMINAL_PANE_SNAPSHOT } from "./terminalPaneVisibility";
import type { Workspace } from "../types";
const createWorkspace = (
id: string,
sessionIds: string[],
options: Partial<Pick<Workspace, "viewMode" | "focusedSessionId">> = {},
): Workspace => ({
id,
title: id,
root: {
id: `${id}-root`,
type: "split",
direction: "vertical",
children: sessionIds.map((sessionId) => ({
id: `${id}-${sessionId}`,
type: "pane",
sessionId,
})),
},
viewMode: options.viewMode ?? "split",
focusedSessionId: options.focusedSessionId,
});
test("terminal pane snapshot stays hidden for panes outside both workspace tabs", () => {
const workspaceById = new Map<string, Workspace>([
["ws-a", createWorkspace("ws-a", ["a-1", "a-2"], { focusedSessionId: "a-1" })],
["ws-b", createWorkspace("ws-b", ["b-1", "b-2"], { focusedSessionId: "b-1" })],
["ws-c", createWorkspace("ws-c", ["c-1"])],
]);
const before = getTerminalPaneSnapshot({
activeTabId: "ws-a",
sessionId: "c-1",
sessionWorkspaceId: "ws-c",
workspaceById,
isTerminalLayerVisible: true,
});
const after = getTerminalPaneSnapshot({
activeTabId: "ws-b",
sessionId: "c-1",
sessionWorkspaceId: "ws-c",
workspaceById,
isTerminalLayerVisible: true,
});
assert.equal(before, HIDDEN_TERMINAL_PANE_SNAPSHOT);
assert.equal(after, before);
});
test("terminal pane snapshot distinguishes solo, split workspace, and focus workspace visibility", () => {
const workspaceById = new Map<string, Workspace>([
["ws-split", createWorkspace("ws-split", ["s-1", "s-2"], { focusedSessionId: "s-1" })],
["ws-focus", createWorkspace("ws-focus", ["f-1", "f-2"], {
viewMode: "focus",
focusedSessionId: "f-2",
})],
]);
assert.equal(
getTerminalPaneSnapshot({
activeTabId: "solo-1",
sessionId: "solo-1",
workspaceById,
isTerminalLayerVisible: true,
}),
"solo|solo-1",
);
assert.equal(
getTerminalPaneSnapshot({
activeTabId: "ws-split",
sessionId: "s-2",
sessionWorkspaceId: "ws-split",
workspaceById,
isTerminalLayerVisible: true,
}),
"workspace|split|ws-split|s-1",
);
assert.equal(
getTerminalPaneSnapshot({
activeTabId: "ws-focus",
sessionId: "f-1",
sessionWorkspaceId: "ws-focus",
workspaceById,
isTerminalLayerVisible: true,
}),
HIDDEN_TERMINAL_PANE_SNAPSHOT,
);
assert.equal(
getTerminalPaneSnapshot({
activeTabId: "ws-focus",
sessionId: "f-2",
sessionWorkspaceId: "ws-focus",
workspaceById,
isTerminalLayerVisible: true,
}),
"workspace|focus|ws-focus|f-2",
);
});

View File

@@ -0,0 +1,82 @@
import type { Workspace } from "../types";
export const HIDDEN_TERMINAL_PANE_SNAPSHOT = "hidden";
export type TerminalPaneSnapshot =
| typeof HIDDEN_TERMINAL_PANE_SNAPSHOT
| `solo|${string}`
| `workspace|split|${string}|${string}`
| `workspace|focus|${string}|${string}`;
interface GetTerminalPaneSnapshotOptions {
activeTabId: string | null;
sessionId: string;
sessionWorkspaceId?: string;
workspaceById: Map<string, Workspace>;
isTerminalLayerVisible: boolean;
}
export function getTerminalPaneSnapshot({
activeTabId,
sessionId,
sessionWorkspaceId,
workspaceById,
isTerminalLayerVisible,
}: GetTerminalPaneSnapshotOptions): TerminalPaneSnapshot {
if (!isTerminalLayerVisible || !activeTabId) {
return HIDDEN_TERMINAL_PANE_SNAPSHOT;
}
const activeWorkspace = workspaceById.get(activeTabId);
if (activeWorkspace) {
if (sessionWorkspaceId !== activeWorkspace.id) {
return HIDDEN_TERMINAL_PANE_SNAPSHOT;
}
const focusedSessionId = activeWorkspace.focusedSessionId ?? "";
if (activeWorkspace.viewMode === "focus") {
return sessionId === focusedSessionId
? `workspace|focus|${activeWorkspace.id}|${focusedSessionId}`
: HIDDEN_TERMINAL_PANE_SNAPSHOT;
}
return `workspace|split|${activeWorkspace.id}|${focusedSessionId}`;
}
return activeTabId === sessionId
? `solo|${sessionId}`
: HIDDEN_TERMINAL_PANE_SNAPSHOT;
}
export function parseTerminalPaneSnapshot(snapshot: TerminalPaneSnapshot): {
isVisible: boolean;
mode: "hidden" | "solo" | "split" | "focus";
workspaceId: string | null;
focusedSessionId: string | null;
} {
if (snapshot === HIDDEN_TERMINAL_PANE_SNAPSHOT) {
return {
isVisible: false,
mode: "hidden",
workspaceId: null,
focusedSessionId: null,
};
}
const parts = snapshot.split("|");
if (parts[0] === "solo") {
return {
isVisible: true,
mode: "solo",
workspaceId: null,
focusedSessionId: null,
};
}
return {
isVisible: true,
mode: parts[1] === "focus" ? "focus" : "split",
workspaceId: parts[2] || null,
focusedSessionId: parts[3] || null,
};
}

View File

@@ -0,0 +1,104 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Workspace } from "../types";
import { getScopedTopTabsThemeId } from "./terminalTopTabsTheme.ts";
const workspace = (sessionIds: string[], viewMode?: Workspace["viewMode"]): Workspace => ({
id: "workspace-1",
title: "Workspace",
viewMode,
focusedSessionId: sessionIds[0],
root: {
id: "split-1",
type: "split",
direction: "vertical",
children: sessionIds.map((sessionId) => ({
id: `pane-${sessionId}`,
type: "pane",
sessionId,
})),
},
});
const resolveThemeFrom = (themes: Record<string, string>) => (sessionId: string) => themes[sessionId] ?? null;
test("top tabs use root immersive theme for normal single-session tab switching", () => {
assert.equal(
getScopedTopTabsThemeId({
activeSidePanelTab: null,
activeThemePreviewId: null,
activeWorkspace: undefined,
followAppTerminalTheme: false,
isVisible: true,
previewTargetSessionId: "s1",
previewedOrVisibleThemeId: "tokyo-night",
resolveSessionThemeId: resolveThemeFrom({ s1: "tokyo-night" }),
}),
null,
);
});
test("top tabs are scoped while previewing a terminal theme", () => {
assert.equal(
getScopedTopTabsThemeId({
activeSidePanelTab: "theme",
activeThemePreviewId: "catppuccin",
activeWorkspace: undefined,
followAppTerminalTheme: false,
isVisible: true,
previewTargetSessionId: "s1",
previewedOrVisibleThemeId: "catppuccin",
resolveSessionThemeId: resolveThemeFrom({ s1: "tokyo-night" }),
}),
"catppuccin",
);
});
test("top tabs use root immersive theme for same-theme workspace splits", () => {
assert.equal(
getScopedTopTabsThemeId({
activeSidePanelTab: null,
activeThemePreviewId: null,
activeWorkspace: workspace(["s1", "s2"]),
followAppTerminalTheme: false,
isVisible: true,
previewTargetSessionId: "s1",
previewedOrVisibleThemeId: "tokyo-night",
resolveSessionThemeId: resolveThemeFrom({ s1: "tokyo-night", s2: "tokyo-night" }),
}),
null,
);
});
test("top tabs are scoped for mixed-theme workspace splits", () => {
assert.equal(
getScopedTopTabsThemeId({
activeSidePanelTab: null,
activeThemePreviewId: null,
activeWorkspace: workspace(["s1", "s2"]),
followAppTerminalTheme: false,
isVisible: true,
previewTargetSessionId: "s1",
previewedOrVisibleThemeId: "tokyo-night",
resolveSessionThemeId: resolveThemeFrom({ s1: "tokyo-night", s2: "solarized-light" }),
}),
"tokyo-night",
);
});
test("top tabs avoid scoped theme when following the application theme", () => {
assert.equal(
getScopedTopTabsThemeId({
activeSidePanelTab: null,
activeThemePreviewId: null,
activeWorkspace: workspace(["s1", "s2"]),
followAppTerminalTheme: true,
isVisible: true,
previewTargetSessionId: "s1",
previewedOrVisibleThemeId: "app-theme",
resolveSessionThemeId: resolveThemeFrom({ s1: "tokyo-night", s2: "solarized-light" }),
}),
null,
);
});

View File

@@ -0,0 +1,52 @@
import { collectSessionIds } from "../domain/workspace";
import type { Workspace } from "../types";
export type TopTabsSidePanelTab = "sftp" | "scripts" | "theme" | "ai" | null;
type ScopedTopTabsThemeInput = {
activeSidePanelTab: TopTabsSidePanelTab;
activeThemePreviewId: string | null;
activeWorkspace: Workspace | null | undefined;
followAppTerminalTheme: boolean;
isVisible: boolean;
previewTargetSessionId: string | null;
previewedOrVisibleThemeId: string;
resolveSessionThemeId: (sessionId: string) => string | null;
};
export function getScopedTopTabsThemeId({
activeSidePanelTab,
activeThemePreviewId,
activeWorkspace,
followAppTerminalTheme,
isVisible,
previewTargetSessionId,
previewedOrVisibleThemeId,
resolveSessionThemeId,
}: ScopedTopTabsThemeInput): string | null {
if (activeSidePanelTab === "theme" && previewTargetSessionId && activeThemePreviewId) {
return activeThemePreviewId;
}
if (!isVisible || followAppTerminalTheme || !activeWorkspace || activeWorkspace.viewMode === "focus") {
return null;
}
const sessionIds = collectSessionIds(activeWorkspace.root);
if (sessionIds.length < 2) return null;
let firstThemeId: string | null = null;
for (const sessionId of sessionIds) {
const themeId = resolveSessionThemeId(sessionId);
if (!themeId) continue;
if (firstThemeId == null) {
firstThemeId = themeId;
continue;
}
if (themeId !== firstThemeId) {
return previewedOrVisibleThemeId;
}
}
return null;
}

View File

@@ -16,63 +16,4 @@ const Card = React.forwardRef<
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card,CardContent,CardDescription,CardFooter,CardHeader,CardTitle }
export { Card }

View File

@@ -1,8 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import type { KnownHost } from "./models";
import { upsertKnownHost } from "./knownHosts";
import {
fingerprintFromPublicKey,
normalizeKnownHost,
normalizeKnownHosts,
upsertKnownHost,
} from "./knownHosts";
const knownHost = (overrides: Partial<KnownHost> = {}): KnownHost => ({
id: "kh-existing",
@@ -97,3 +103,158 @@ test("upsertKnownHost appends genuinely new host keys", () => {
assert.deepEqual(result, [existing, incoming]);
});
// --- Fingerprint derivation -------------------------------------------------
const makeRawPublicKey = (keyType: string, body = "trusted imported host key") => {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
};
test("fingerprintFromPublicKey matches Node's SHA-256 over a base64-decoded OpenSSH line", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(`ssh-ed25519 ${base64Body}`), expected);
assert.equal(
fingerprintFromPublicKey(`ssh-ed25519 ${base64Body} comment-tail`),
expected,
"trailing comment is ignored",
);
});
test("fingerprintFromPublicKey strips a SHA256: prefix and trailing padding", () => {
assert.equal(fingerprintFromPublicKey("SHA256:abc123=="), "abc123");
assert.equal(fingerprintFromPublicKey("sha256:abc123"), "abc123");
});
test("fingerprintFromPublicKey returns empty string on missing input", () => {
assert.equal(fingerprintFromPublicKey(undefined), "");
assert.equal(fingerprintFromPublicKey(null), "");
assert.equal(fingerprintFromPublicKey(""), "");
});
// --- Migration --------------------------------------------------------------
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.notEqual(migrated, stored, "should return a new object when fingerprint is added");
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, "ssh-ed25519");
});
test("normalizeKnownHost backfills keyType from an OpenSSH-format publicKey", () => {
const rawKey = makeRawPublicKey("ssh-rsa");
const base64Body = rawKey.toString("base64");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.equal(migrated.keyType, "ssh-rsa");
});
test("normalizeKnownHost returns the same reference when nothing needs backfilling", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
};
assert.equal(normalizeKnownHost(stored), stored);
});
test("normalizeKnownHost is a no-op when publicKey is opaque and nothing else is known", () => {
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "unknown",
publicKey: "SHA256:already-just-a-fingerprint",
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
// The SHA256: prefix becomes the fingerprint; keyType stays as "unknown" since
// we cannot recover it from a bare fingerprint.
assert.equal(migrated.fingerprint, "already-just-a-fingerprint");
assert.equal(migrated.keyType, "unknown");
});
test("normalizeKnownHosts returns the same array reference when nothing needs migration", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
}];
assert.equal(normalizeKnownHosts(list), list);
});
test("normalizeKnownHosts migrates each entry that needs backfilling", () => {
const rawKeyA = makeRawPublicKey("ssh-ed25519", "host-a-key");
const rawKeyB = makeRawPublicKey("ssh-rsa", "host-b-key");
const fpA = crypto.createHash("sha256").update(rawKeyA).digest("base64").replace(/=+$/g, "");
const fpB = crypto.createHash("sha256").update(rawKeyB).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [
{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKeyA.toString("base64")}`,
discoveredAt: 1,
},
{
id: "kh-2",
hostname: "vps-2.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${rawKeyB.toString("base64")}`,
discoveredAt: 2,
},
];
const migrated = normalizeKnownHosts(list);
assert.notEqual(migrated, list);
assert.equal(migrated[0].fingerprint, fpA);
assert.equal(migrated[1].fingerprint, fpB);
assert.equal(migrated[1].keyType, "ssh-rsa");
});

View File

@@ -36,3 +36,157 @@ export const upsertKnownHost = (
...knownHosts.slice(index + 1),
];
};
const SSH_KEY_TYPE_PREFIX = /^(?:ssh-|ecdsa-|sk-)/;
const stripPadding = (value: string) => value.replace(/=+$/g, "");
// Pure-JS SHA-256 used to migrate stored knownHosts records on hydration.
// crypto.subtle is async and would force the migration through useEffect; for
// a one-shot read-and-rewrite of a typically-small list, the sync path keeps
// the call sites simple. Runs at most a handful of times per app start.
const sha256Bytes = (data: Uint8Array): Uint8Array => {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const length = data.length;
const bitLength = BigInt(length) * 8n;
const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
padded.set(data);
padded[length] = 0x80;
const view = new DataView(padded.buffer);
view.setBigUint64(padded.length - 8, bitLength, false);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const W = new Uint32Array(64);
for (let chunk = 0; chunk < padded.length; chunk += 64) {
for (let i = 0; i < 16; i += 1) W[i] = view.getUint32(chunk + i * 4, false);
for (let i = 16; i < 64; i += 1) {
const s0 = ((W[i - 15] >>> 7) | (W[i - 15] << 25)) ^ ((W[i - 15] >>> 18) | (W[i - 15] << 14)) ^ (W[i - 15] >>> 3);
const s1 = ((W[i - 2] >>> 17) | (W[i - 2] << 15)) ^ ((W[i - 2] >>> 19) | (W[i - 2] << 13)) ^ (W[i - 2] >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H;
for (let i = 0; i < 64; i += 1) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) >>> 0;
h = g; g = f; f = e; e = (d + temp1) >>> 0;
d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
}
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
}
const out = new Uint8Array(32);
const outView = new DataView(out.buffer);
for (let i = 0; i < 8; i += 1) outView.setUint32(i * 4, H[i], false);
return out;
};
const base64Decode = (value: string): Uint8Array | null => {
try {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
} catch {
return null;
}
};
const base64Encode = (bytes: Uint8Array): string => {
let bin = "";
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
};
/**
* Compute the SHA-256 base64 fingerprint (no padding, no SHA256: prefix) from
* a stored `publicKey` field. Mirrors `fingerprintFromPublicKey` in
* electron/bridges/hostKeyVerifier.cjs so renderer-side migration produces the
* same value the verifier compares against at connect time.
*/
export const fingerprintFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const trimmed = publicKey.trim();
if (!trimmed) return "";
if (/^SHA256:/i.test(trimmed)) {
return stripPadding(trimmed.replace(/^SHA256:/i, ""));
}
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
}
return stripPadding(trimmed);
};
const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const first = publicKey.trim().split(/\s+/)[0] ?? "";
return SSH_KEY_TYPE_PREFIX.test(first) ? first : "";
};
/**
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
const hasFingerprint = typeof knownHost.fingerprint === "string" && knownHost.fingerprint.length > 0;
const hasKeyType = typeof knownHost.keyType === "string"
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
if (hasFingerprint && hasKeyType) return knownHost;
const derivedFingerprint = hasFingerprint
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
? knownHost.keyType
: extractKeyTypeFromPublicKey(knownHost.publicKey);
const fingerprintChanged = derivedFingerprint && derivedFingerprint !== knownHost.fingerprint;
const keyTypeChanged = derivedKeyType && derivedKeyType !== knownHost.keyType;
if (!fingerprintChanged && !keyTypeChanged) return knownHost;
return {
...knownHost,
fingerprint: fingerprintChanged ? derivedFingerprint : knownHost.fingerprint,
keyType: keyTypeChanged ? derivedKeyType : knownHost.keyType,
};
};
/**
* Normalize a whole list. Returns the same array reference when no entries
* needed migration so referential-equality consumers (React.memo, prop
* comparisons in TerminalLayer) don't re-render on every hydration.
*/
export const normalizeKnownHosts = (knownHosts: KnownHost[]): KnownHost[] => {
let changed = false;
const next = knownHosts.map((entry) => {
const normalized = normalizeKnownHost(entry);
if (normalized !== entry) changed = true;
return normalized;
});
return changed ? next : knownHosts;
};

View File

@@ -544,6 +544,11 @@ export interface TerminalSettings {
// on input; this opt-in toggle restores the selection right after.
preserveSelectionOnInput: boolean;
// When the final visible output line from a command is not terminated by a
// newline, move a recognized shell prompt to the next visual line. This is
// display-only; raw session logs keep the original byte stream.
forcePromptNewLine: boolean;
// Clipboard
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
@@ -715,6 +720,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
disableBracketedPaste: false, // Bracketed paste enabled by default
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
forcePromptNewLine: true, // Keep the next shell prompt visually separated from unterminated final output lines
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
autocompleteEnabled: true, // Autocomplete enabled by default
@@ -810,6 +816,7 @@ export interface Workspace {
root: WorkspaceNode;
viewMode?: WorkspaceViewMode; // 'split' = tiled view (default), 'focus' = left list + single terminal
focusedSessionId?: string; // Which session is focused when in focus mode
focusSessionOrder?: string[]; // User-defined session order for the focus-mode sidebar
snippetId?: string; // If this workspace was created from running a snippet
}

View File

@@ -241,6 +241,44 @@ export interface SyncPayload {
syncedAt: number; // When this payload was created
}
export const SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'portForwardingRules',
'knownHosts',
'groupConfigs',
] as const;
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
] as const;
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
export function hasSyncPayloadEntityData(
payload: SyncPayload,
keys: readonly SyncPayloadEntityKey[] = SYNC_PAYLOAD_ENTITY_KEYS,
): boolean {
return keys.some((key) => {
const value = payload[key];
return Array.isArray(value) && value.length > 0;
});
}
// ============================================================================
// Encryption Types
// ============================================================================

View File

@@ -1,4 +1,8 @@
import type { SyncPayload } from './sync';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
type CloudSyncPayloadEntityKey,
type SyncPayload,
} from './sync';
export type ShrinkFinding =
| { suspicious: false }
@@ -22,22 +26,9 @@ export type ShrinkFinding =
viaRemote?: boolean;
};
// Keep in sync with all array-typed fields of SyncPayload. When a new
// array entity type is added there, add it here too — there is no
// compile-time check enforcing this.
const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
] as const;
const CHECKED_ENTITIES = CLOUD_SYNC_PAYLOAD_ENTITY_KEYS;
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
type CheckedEntityType = CloudSyncPayloadEntityKey;
const BULK_SHRINK_RATIO = 0.5;
const BULK_SHRINK_MIN_ABSOLUTE = 3;

44
domain/workspace.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { WorkspaceNode } from "./models.ts";
import {
reorderWorkspaceFocusSessionOrder,
resolveWorkspaceFocusSessionOrder,
} from "./workspace.ts";
const root: WorkspaceNode = {
id: "split-1",
type: "split",
direction: "vertical",
children: [
{ id: "pane-1", type: "pane", sessionId: "s1" },
{ id: "pane-2", type: "pane", sessionId: "s2" },
{ id: "pane-3", type: "pane", sessionId: "s3" },
],
};
test("resolveWorkspaceFocusSessionOrder follows tree order when no saved order exists", () => {
assert.deepEqual(resolveWorkspaceFocusSessionOrder(root), ["s1", "s2", "s3"]);
});
test("resolveWorkspaceFocusSessionOrder drops stale ids and appends new panes", () => {
assert.deepEqual(
resolveWorkspaceFocusSessionOrder(root, ["stale", "s3", "s1"]),
["s3", "s1", "s2"],
);
});
test("reorderWorkspaceFocusSessionOrder moves a session before a target", () => {
assert.deepEqual(
reorderWorkspaceFocusSessionOrder(root, undefined, "s3", "s1", "before"),
["s3", "s1", "s2"],
);
});
test("reorderWorkspaceFocusSessionOrder moves a session after a target", () => {
assert.deepEqual(
reorderWorkspaceFocusSessionOrder(root, ["s1", "s2", "s3"], "s1", "s3", "after"),
["s2", "s3", "s1"],
);
});

View File

@@ -144,6 +144,7 @@ export const createWorkspaceFromSessions = (
id: `ws-${crypto.randomUUID()}`,
title: 'Workspace',
focusedSessionId: baseSessionId, // Initialize with the base session focused
focusSessionOrder: [baseSessionId, joiningSessionId],
root: {
id: crypto.randomUUID(),
type: 'split',
@@ -171,6 +172,47 @@ export const updateWorkspaceSplitSizes = (
return patch(root);
};
export const resolveWorkspaceFocusSessionOrder = (
root: WorkspaceNode,
savedOrder?: string[],
): string[] => {
const sessionIds = collectSessionIds(root);
if (!savedOrder?.length) return sessionIds;
const sessionIdSet = new Set(sessionIds);
const ordered = savedOrder.filter((id, index) => (
sessionIdSet.has(id) && savedOrder.indexOf(id) === index
));
const orderedSet = new Set(ordered);
return [...ordered, ...sessionIds.filter((id) => !orderedSet.has(id))];
};
export const reorderWorkspaceFocusSessionOrder = (
root: WorkspaceNode,
savedOrder: string[] | undefined,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
): string[] => {
if (draggedSessionId === targetSessionId) {
return resolveWorkspaceFocusSessionOrder(root, savedOrder);
}
const currentOrder = resolveWorkspaceFocusSessionOrder(root, savedOrder);
const draggedIndex = currentOrder.indexOf(draggedSessionId);
const targetIndex = currentOrder.indexOf(targetSessionId);
if (draggedIndex === -1 || targetIndex === -1) return currentOrder;
currentOrder.splice(draggedIndex, 1);
let insertIndex = targetIndex;
if (draggedIndex < targetIndex) insertIndex -= 1;
if (position === 'after') insertIndex += 1;
currentOrder.splice(insertIndex, 0, draggedSessionId);
return currentOrder;
};
/**
* Create a workspace from multiple session IDs.
* Used for snippet runner - creates a workspace with all sessions in a horizontal split.
@@ -195,6 +237,7 @@ export const createWorkspaceFromSessionIds = (
viewMode: options.viewMode,
snippetId: options.snippetId,
focusedSessionId: sessionIds[0],
focusSessionOrder: [sessionIds[0]],
root: {
id: crypto.randomUUID(),
type: 'pane',
@@ -216,6 +259,7 @@ export const createWorkspaceFromSessionIds = (
viewMode: options.viewMode,
snippetId: options.snippetId,
focusedSessionId: sessionIds[0],
focusSessionOrder: sessionIds,
root: {
id: crypto.randomUUID(),
type: 'split',

View File

@@ -33,6 +33,7 @@ module.exports = {
'dist/**/*',
'electron/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'!electron/.dev-config.json',
'skills/**/*',
'public/**/*',

View File

@@ -81,6 +81,17 @@ const getKnownHostFingerprint = (knownHost) => {
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
// Classification rules, in order:
// 1. Any record for (host, port) whose fingerprint matches the live key →
// trusted. Fingerprint is the ground truth; key type is metadata.
// 2. A record matching (host, port, keyType) *exactly* with a non-matching
// fingerprint → changed. Only this case is a real "key rotated" alarm —
// the user already trusted this exact algorithm on this host and the
// server now presents a different key of the same type.
// 3. Otherwise → unknown. This includes the case where the server presents
// a key of an algorithm we have no record for, even if the host has
// records for other algorithms. Tabby and OpenSSH both treat that as a
// first-time prompt rather than a mismatch warning (#972).
const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, fingerprint }) => {
const normalizedFingerprint = normalizeFingerprint(fingerprint);
const candidates = Array.isArray(knownHosts)
@@ -104,26 +115,17 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
}
const normalizedKeyType = typeof keyType === "string" ? keyType.trim() : "";
const hasSpecificIncomingType = normalizedKeyType && normalizedKeyType !== "unknown";
let sameTypeMismatch;
if (hasSpecificIncomingType) {
sameTypeMismatch = comparableCandidates.find((entry) => entry.knownHost.keyType === normalizedKeyType);
if (!sameTypeMismatch && comparableCandidates.length === 1) {
const onlyCandidate = comparableCandidates[0];
if (!onlyCandidate.knownHost.keyType || onlyCandidate.knownHost.keyType === "unknown") {
sameTypeMismatch = onlyCandidate;
}
if (normalizedKeyType && normalizedKeyType !== "unknown") {
const sameTypeMismatch = comparableCandidates.find(
(entry) => entry.knownHost.keyType === normalizedKeyType,
);
if (sameTypeMismatch) {
return {
status: "changed",
knownHost: sameTypeMismatch.knownHost,
expectedFingerprint: sameTypeMismatch.fingerprint,
};
}
} else if (comparableCandidates.length === 1) {
sameTypeMismatch = comparableCandidates[0];
}
if (sameTypeMismatch) {
return {
status: "changed",
knownHost: sameTypeMismatch.knownHost,
expectedFingerprint: sameTypeMismatch.fingerprint,
};
}
return { status: "unknown" };

Some files were not shown because too many files have changed in this diff Show More