Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 | ||
|
|
f4aa6ddb46 | ||
|
|
f6cb73fdd6 | ||
|
|
3c100b0ae2 | ||
|
|
168e42b5fa | ||
|
|
2ce6bd5ed1 | ||
|
|
7bd5d6465a | ||
|
|
65387d4c61 | ||
|
|
6084e8e94f | ||
|
|
3ccc5c9fc6 | ||
|
|
d07859f604 | ||
|
|
88a322a03b | ||
|
|
0e02bbc2fb | ||
|
|
affd9217e2 | ||
|
|
7b4a349e3f | ||
|
|
7dc5ab5035 | ||
|
|
3e8965f9a9 | ||
|
|
23a27bf544 | ||
|
|
86a815ad46 | ||
|
|
cb4fb091aa | ||
|
|
b30696c98b | ||
|
|
6b8f05c65a | ||
|
|
64dd3a4a2f | ||
|
|
88732040aa | ||
|
|
b9f3bfa8bb | ||
|
|
b7ec3c12f7 | ||
|
|
d20a18b862 | ||
|
|
ff6b4a4625 | ||
|
|
5a94b4cf39 | ||
|
|
3963cd4af9 | ||
|
|
5b2a048917 | ||
|
|
2414cb00e4 | ||
|
|
03f980e939 | ||
|
|
ac819fd4fd | ||
|
|
fb9400a5fb | ||
|
|
7da983a56c | ||
|
|
344b226ce8 | ||
|
|
86e47b5f9e |
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable file
89
.github/scripts/bump-homebrew-cask.sh
vendored
Executable 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}."
|
||||
30
.github/workflows/build.yml
vendored
30
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
48
App.tsx
48
App.tsx
@@ -28,7 +28,12 @@ import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
|
||||
@@ -177,12 +182,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 +361,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
setWorkspaceFocusedSession,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
@@ -1715,6 +1731,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateSessionStatus, updateHostLastConnected]);
|
||||
|
||||
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
|
||||
updateHosts(hosts.map((h) => (
|
||||
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1741,15 +1763,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
const matchingLog = selectConnectionLogForTerminalDataCapture(
|
||||
connectionLogs,
|
||||
{ sessionId, hostname: session?.hostname },
|
||||
);
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
|
||||
@@ -2011,7 +2028,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 +2116,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 +2131,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
|
||||
@@ -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',
|
||||
@@ -1926,7 +1935,7 @@ const en: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
|
||||
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
|
||||
'ai.claude.detecting': 'Detecting...',
|
||||
'ai.claude.detected': 'Detected',
|
||||
'ai.claude.notFound': 'Not found',
|
||||
|
||||
2130
application/i18n/locales/ru.ts
Normal file
2130
application/i18n/locales/ru.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 转义序列访问本地剪贴板。',
|
||||
@@ -1935,7 +1944,7 @@ const zhCN: Messages = {
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
|
||||
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
|
||||
'ai.claude.detecting': '检测中...',
|
||||
'ai.claude.detected': '已检测到',
|
||||
'ai.claude.notFound': '未找到',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
32
application/state/resolveTerminalSessionExitIntent.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
22
application/state/resolveTerminalSessionExitIntent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
23
application/state/sftp/bookmarkHelpers.ts
Normal file
23
application/state/sftp/bookmarkHelpers.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
45
application/state/sftp/globalSftpBookmarks.ts
Normal file
45
application/state/sftp/globalSftpBookmarks.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
11
application/state/sftp/utils.test.ts
Normal file
11
application/state/sftp/utils.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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] || "";
|
||||
|
||||
@@ -52,14 +52,19 @@ export function useAgentDiscovery(
|
||||
);
|
||||
if (!match) return ea;
|
||||
|
||||
// Check if args or ACP config differ
|
||||
// Check if args, ACP config, or Claude's resolved system path differ
|
||||
const currentArgs = JSON.stringify(ea.args || []);
|
||||
const newArgs = JSON.stringify(match.args);
|
||||
const acpChanged = ea.acpCommand !== match.acpCommand
|
||||
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
|
||||
if (currentArgs !== newArgs || acpChanged) {
|
||||
const env = match.command === 'claude'
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
|
||||
: ea.env;
|
||||
const envChanged = match.command === 'claude'
|
||||
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
|
||||
if (currentArgs !== newArgs || acpChanged || envChanged) {
|
||||
changed = true;
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
|
||||
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
|
||||
}
|
||||
return ea;
|
||||
});
|
||||
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
|
||||
enabled: true,
|
||||
acpCommand: agent.acpCommand,
|
||||
acpArgs: agent.acpArgs,
|
||||
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -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 {
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +99,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -156,21 +165,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 +173,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
@@ -417,17 +411,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -741,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -637,6 +637,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
currentAgentConfig.env,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
// If the probe came back empty, drop any stale cached catalog for this
|
||||
|
||||
@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
|
||||
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
|
||||
}
|
||||
|
||||
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
@@ -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,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
@@ -63,6 +69,9 @@ import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
@@ -163,6 +172,7 @@ interface TerminalProps {
|
||||
pendingUploadEntries?: DropEntry[],
|
||||
sourceSessionId?: string,
|
||||
) => void;
|
||||
onTerminalCwdChange?: (sessionId: string, cwd: string | null) => void;
|
||||
onOpenScripts?: () => void;
|
||||
onOpenTheme?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
@@ -253,6 +263,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
onTerminalCwdChange,
|
||||
onOpenScripts,
|
||||
onOpenTheme,
|
||||
isBroadcastEnabled,
|
||||
@@ -273,6 +284,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const terminalCwdTracker = useMemo(() => createTerminalCwdTracker(), []);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -285,8 +297,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 +315,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 +478,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 +501,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 {
|
||||
@@ -477,9 +518,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
@@ -510,7 +556,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
@@ -519,9 +565,40 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
|
||||
useEffect(() => {
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
|
||||
const clearTerminalCwd = useCallback(() => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
}, [onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTerminalCwd();
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
@@ -531,10 +608,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
@@ -546,7 +634,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
@@ -558,25 +646,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -754,12 +827,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 +887,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
pendingAuthRef,
|
||||
promptLineBreakStateRef,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
setError,
|
||||
@@ -822,6 +899,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
clearTerminalCwd();
|
||||
// SSH: always sync. Its backend starts in utf-8 regardless of
|
||||
// host.charset, so the push is what keeps the UI state aligned
|
||||
// across reconnects — including localhost SSH targets, hence
|
||||
@@ -845,8 +923,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
@@ -856,6 +938,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 +947,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setShowLogs(false);
|
||||
setIsCancelling(false);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
promptLineBreakStateRef.current = createPromptLineBreakState();
|
||||
|
||||
const boot = async () => {
|
||||
try {
|
||||
@@ -887,13 +972,17 @@ 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) => {
|
||||
terminalCwdTracker.setRendererCwd(cwd);
|
||||
knownCwdRef.current = cwd;
|
||||
onTerminalCwdChange?.(sessionId, cwd);
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
@@ -1163,6 +1252,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
@@ -1396,6 +1490,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 +1514,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 +1601,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
|
||||
@@ -1528,17 +1626,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleOpenSFTP = async () => {
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, undefined, sessionId);
|
||||
return;
|
||||
}
|
||||
@@ -1751,17 +1839,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload via parent
|
||||
if (onOpenSftp) {
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
onOpenSftp(host, initialPath, dropEntries, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -1820,6 +1898,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
onSplitHorizontal={onSplitHorizontal}
|
||||
onSplitVertical={onSplitVertical}
|
||||
isReconnectable={status === "disconnected"}
|
||||
onReconnect={handleRetry}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -35,6 +35,8 @@ const baseProps = {
|
||||
onAddKnownHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
@@ -96,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isBroadcastEnabled: () => true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, onToggleBroadcast: () => {} } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ interface TextEditorModalProps {
|
||||
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
|
||||
}
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
|
||||
@@ -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
@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficDiagram;
|
||||
|
||||
142
components/VaultView.sortPersistence.test.tsx
Normal file
142
components/VaultView.sortPersistence.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { Host, SSHKey } from "../types.ts";
|
||||
import { VaultView } from "./VaultView.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const installStorageStub = (sortMode: string | null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (sortMode !== null) {
|
||||
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
|
||||
id,
|
||||
label,
|
||||
hostname: `${id}.example.com`,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
createdAt,
|
||||
group,
|
||||
});
|
||||
|
||||
const fallbackKey: SSHKey = {
|
||||
id: "key-1",
|
||||
label: "Fallback key",
|
||||
type: "ED25519",
|
||||
privateKey: "",
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
installStorageStub(sortMode);
|
||||
const noop = () => {};
|
||||
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
managedSources: [],
|
||||
sessionCount: 0,
|
||||
hotkeyScheme: "mac",
|
||||
keyBindings: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onOpenSettings: noop,
|
||||
onOpenQuickSwitcher: noop,
|
||||
onCreateLocalTerminal: noop,
|
||||
onDeleteHost: noop,
|
||||
onConnect: noop,
|
||||
onUpdateHosts: noop,
|
||||
onUpdateKeys: noop,
|
||||
onImportOrReuseKey: () => fallbackKey,
|
||||
onUpdateIdentities: noop,
|
||||
onUpdateProxyProfiles: noop,
|
||||
onUpdateSnippets: noop,
|
||||
onUpdateSnippetPackages: noop,
|
||||
onUpdateCustomGroups: noop,
|
||||
onUpdateKnownHosts: noop,
|
||||
onUpdateManagedSources: noop,
|
||||
onConvertKnownHost: noop,
|
||||
onToggleConnectionLogSaved: noop,
|
||||
onDeleteConnectionLog: noop,
|
||||
onClearUnsavedConnectionLogs: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("Hosts sort mode is restored from storage", () => {
|
||||
const markup = renderVault("za", [
|
||||
host("alpha", "Alpha Host", 1),
|
||||
host("zulu", "Zulu Host", 2),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
|
||||
});
|
||||
|
||||
test("Hosts grouped sort mode is restored from storage", () => {
|
||||
const markup = renderVault("group", [
|
||||
host("beta", "Beta Host", 1, "Beta Group"),
|
||||
host("alpha", "Alpha Host", 2, "Alpha Group"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
|
||||
const markup = renderVault("unknown-sort", [
|
||||
host("zulu", "Zulu Host", 2),
|
||||
host("alpha", "Alpha Host", 1),
|
||||
]);
|
||||
|
||||
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredString } from "../application/state/useStoredString";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
@@ -50,6 +51,7 @@ import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -70,7 +72,6 @@ import {
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
TerminalSession,
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
@@ -122,6 +123,13 @@ type DropTarget =
|
||||
| { kind: "root" }
|
||||
| { kind: "group"; path: string };
|
||||
|
||||
const isSortMode = (value: string): value is SortMode =>
|
||||
value === "az" ||
|
||||
value === "za" ||
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "group";
|
||||
|
||||
// Props without isActive - it's now subscribed internally
|
||||
interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
@@ -135,7 +143,7 @@ interface VaultViewProps {
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
sessionCount: number;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
terminalThemeId: string;
|
||||
@@ -187,7 +195,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
sessionCount,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
terminalThemeId,
|
||||
@@ -281,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
||||
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
||||
"az",
|
||||
isSortMode,
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
@@ -2511,7 +2523,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 +3303,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 &&
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
76
components/ai-elements/streamdownCodeHighlighter.ts
Normal file
76
components/ai-elements/streamdownCodeHighlighter.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -143,6 +143,7 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
agentEnv?: Record<string, string>,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiUserSkillsGetStatus?: () => Promise<{
|
||||
|
||||
@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
|
||||
assert.equal(state.defaultAgentId, 'custom-agent');
|
||||
});
|
||||
|
||||
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
|
||||
const state = buildManagedAgentState(
|
||||
[],
|
||||
'catty',
|
||||
'claude',
|
||||
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
|
||||
);
|
||||
|
||||
assert.equal(state.agents.length, 1);
|
||||
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
|
||||
assert.deepEqual(state.agents[0].env, {
|
||||
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildManagedAgentState does not remove user-created matching agents', () => {
|
||||
const agents: ExternalAgentConfig[] = [
|
||||
{
|
||||
|
||||
90
components/ai/streamdownCodeHighlighter.test.ts
Normal file
90
components/ai/streamdownCodeHighlighter.test.ts
Normal 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'],
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
|
||||
@@ -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";
|
||||
@@ -47,11 +47,15 @@ export function buildManagedAgentState(
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const managedEnv = agentKey === "claude"
|
||||
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
|
||||
: existingManaged?.env;
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
...(managedEnv ? { env: managedEnv } : {}),
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -450,3 +450,32 @@ test("applyKeystroke: ignores non-typing data (escape sequences, control codes)"
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// We believe only "network in" is typed; suggestion is the full command.
|
||||
addon.show("network interface show", "network in");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// The real line shows MORE than we tracked: a bastion host echoed the
|
||||
// next char ("t") that our client-side buffer never recorded.
|
||||
const line = "ecOS# network int";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = line.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
69
components/terminal/TerminalContextMenu.test.ts
Normal file
69
components/terminal/TerminalContextMenu.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -91,6 +91,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
@@ -361,6 +387,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
|
||||
|
||||
/**
|
||||
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
|
||||
@@ -112,9 +113,16 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.isVisible()) {
|
||||
this.updatePosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013), hide rather than draw the ghost over
|
||||
// already-typed text. Done here (post-echo render) rather than in
|
||||
// show()/adjustToInput so it never fights the keystroke-time path.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -291,6 +299,23 @@ export class GhostTextAddon implements IDisposable {
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the real terminal line has more input than we tracked, so
|
||||
* rendering the ghost would paint over already-typed characters. See
|
||||
* ./ghostTextConsistency and issue #1013. Returns false on hosts/inputs
|
||||
* we can't judge (non-ASCII, echo still catching up), so the ghost only
|
||||
* gets suppressed when corruption is actually imminent.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term || !this.currentInput) return false;
|
||||
const buf = this.term.buffer.active;
|
||||
if (typeof buf?.getLine !== "function") return false;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
if (!line || typeof line.translateToString !== "function") return false;
|
||||
const beforeCursor = line.translateToString(false).slice(0, buf.cursorX);
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Fail-safe consistency check for inline (ghost-text) suggestions.
|
||||
*
|
||||
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
|
||||
* cursor, where `trackedInput` is what the client thinks the user has typed.
|
||||
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
|
||||
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
|
||||
* of sync with what is actually on the terminal line, and the ghost ends up
|
||||
* painted over characters the user already typed (`int` + ghost `terface` →
|
||||
* `intterface`).
|
||||
*
|
||||
* This detects the one direction that produces visible corruption: the real
|
||||
* line being AHEAD of the tracked input (it contains the tracked input
|
||||
* followed by more, untracked characters). SSH echo latency is the opposite
|
||||
* case — the line is a prefix-behind of the tracked input — and is
|
||||
* intentionally NOT flagged, so the ghost stays responsive on slow links.
|
||||
*
|
||||
* Returns true when the caller should hide the ghost.
|
||||
*/
|
||||
export function lineHasUntrackedTrailingInput(
|
||||
trackedInput: string,
|
||||
lineBeforeCursor: string,
|
||||
): boolean {
|
||||
// Single chars match too loosely to judge reliably; let them through.
|
||||
if (trackedInput.length < 2) return false;
|
||||
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
|
||||
// existing wide-char (CJK / emoji) handling is left untouched.
|
||||
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
|
||||
|
||||
// Use the last occurrence so a prompt or command that repeats the same token
|
||||
// earlier on the line doesn't shadow the freshly-typed input.
|
||||
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
|
||||
if (idx < 0) {
|
||||
// Tracked input isn't on screen yet — the echo is still catching up
|
||||
// (latency). Keep the ghost; reality being behind never corrupts.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-whitespace characters between the tracked input and the cursor mean the
|
||||
// device echoed input we never tracked → the ghost would overlap real text.
|
||||
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
@@ -20,7 +20,29 @@ const NON_PROMPT_PATTERNS = [
|
||||
/^:\s*$/, // vim command mode
|
||||
/^\s*~\s*$/, // vim tilde lines
|
||||
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
|
||||
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
|
||||
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
|
||||
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
|
||||
/^SQL>\s/i, // sqlplus SQL> prompts
|
||||
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
|
||||
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
|
||||
/^pry\([^)]*\)>\s/i,
|
||||
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
|
||||
/^lftp\s+\S+>\s/i,
|
||||
/^\s{3}\.{3}>\s/,
|
||||
/^cqlsh(?::[\w.-]+)?>\s/i,
|
||||
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
|
||||
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
|
||||
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
|
||||
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
|
||||
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
|
||||
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
|
||||
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
|
||||
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
|
||||
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
|
||||
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
|
||||
];
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
@@ -38,30 +60,48 @@ const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
(trimmed.endsWith(">") || trimmed.endsWith("›"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
|
||||
}
|
||||
|
||||
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (/^root[#%$]$/.test(trimmed)) return true;
|
||||
if (trimmed.length < 3) return false;
|
||||
|
||||
const marker = trimmed[trimmed.length - 1];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
|
||||
|
||||
const prev = trimmed[trimmed.length - 2] ?? "";
|
||||
return /[~:/\\\])]/.test(prev);
|
||||
}
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* actually matches the tail of the raw detected userInput. Returns
|
||||
* null otherwise — the single signal downstream uses to decide
|
||||
* whether to record it as the executed command.
|
||||
* can be validated against the live terminal line. Returns null
|
||||
* otherwise - the single signal downstream uses to decide whether
|
||||
* to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
}
|
||||
|
||||
function replacePromptUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
userInput: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText,
|
||||
userInput,
|
||||
cursorOffset: userInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorLinePrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
@@ -72,6 +112,499 @@ function getCursorLinePrefix(term: XTerm): string | null {
|
||||
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
|
||||
}
|
||||
|
||||
function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line?.isWrapped) return null;
|
||||
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) return null;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (!promptLine) return null;
|
||||
|
||||
let prefix = promptLine.translateToString(false);
|
||||
for (let row = promptRow + 1; row < cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (!rowLine) return null;
|
||||
prefix += rowLine.translateToString(false);
|
||||
}
|
||||
|
||||
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
|
||||
}
|
||||
|
||||
function inferPromptTextBeforeTypedInput(
|
||||
cursorPrefix: string,
|
||||
typedBuffer: string,
|
||||
allowPartialEcho: boolean,
|
||||
): string | null {
|
||||
if (cursorPrefix.endsWith(typedBuffer)) {
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
|
||||
return promptText.length > 0 ? promptText : null;
|
||||
}
|
||||
|
||||
if (!allowPartialEcho) return null;
|
||||
|
||||
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
|
||||
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
|
||||
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (promptText.length > 0) return promptText;
|
||||
}
|
||||
|
||||
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
|
||||
for (
|
||||
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
|
||||
echoLength >= noSpacePromptMinEchoLength;
|
||||
echoLength--
|
||||
) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
const hasReliablePartialEcho =
|
||||
typedBuffer.trim().length <= 2 ||
|
||||
echoedInput.endsWith(" ") ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4);
|
||||
if (!hasReliablePartialEcho) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
|
||||
const candidate = promptText.slice(0, promptBoundary).trimEnd();
|
||||
const finalIndex = candidate.length - 1;
|
||||
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
|
||||
|
||||
for (let i = 0; i < finalIndex; i++) {
|
||||
const ch = candidate[i];
|
||||
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
|
||||
|
||||
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
|
||||
if (nextChar === null || nextChar === " ") continue;
|
||||
|
||||
const earlierPrompt = candidate.slice(0, i + 1);
|
||||
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
|
||||
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
|
||||
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
|
||||
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
|
||||
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
|
||||
if (finalChar !== "$") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
|
||||
if (promptText.length === 0) return false;
|
||||
if (rawIsAtPrompt) return true;
|
||||
|
||||
const promptBoundary = findPromptBoundary(promptText);
|
||||
const promptEndsAtBoundary =
|
||||
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
return (
|
||||
promptEndsAtBoundary &&
|
||||
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
|
||||
isSpecificShellPromptCandidate(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isThemedPromptText(promptText: string): boolean {
|
||||
for (const ch of promptText) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return /[❯❮→➜➤⟩»›]/.test(promptText);
|
||||
}
|
||||
|
||||
function isPromptPathDecoration(trimmed: string): boolean {
|
||||
return (
|
||||
trimmed === "~" ||
|
||||
trimmed.startsWith("~/") ||
|
||||
trimmed.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
|
||||
trimmed.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptBareDirectoryText(trimmed: string): boolean {
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
|
||||
return /^[\w.-]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function isPromptStatusToken(token: string): boolean {
|
||||
return (
|
||||
/^git:\([^)]*\)$/.test(token) ||
|
||||
/^[+$#%>!?*]$/.test(token) ||
|
||||
token === "✗" ||
|
||||
token === "✔"
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptStatusText(trimmed: string): boolean {
|
||||
const [first = "", ...rest] = trimmed.split(/\s+/);
|
||||
if (rest.length === 0) return false;
|
||||
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
|
||||
return rest.every(isPromptStatusToken);
|
||||
}
|
||||
|
||||
function isPromptStatusDecoration(extra: string): boolean {
|
||||
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
|
||||
|
||||
return isPromptStatusText(extra.trim());
|
||||
}
|
||||
|
||||
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (!isThemedPromptText(promptText)) return false;
|
||||
if (startsWithCommonShellCommand(extra)) return false;
|
||||
if (/^\s*\S+\s+$/.test(extra)) {
|
||||
return isPromptPathDecoration(trimmed) || (
|
||||
isPromptBareDirectoryText(trimmed) &&
|
||||
!startsWithCommonShellCommand(trimmed)
|
||||
);
|
||||
}
|
||||
if (isPromptStatusDecoration(extra)) return true;
|
||||
for (const ch of extra) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFinalPromptBoundary(promptText: string): number {
|
||||
const trimmedEnd = promptText.trimEnd().length;
|
||||
if (trimmedEnd === 0) return -1;
|
||||
|
||||
const markerIndex = trimmedEnd - 1;
|
||||
const marker = promptText[markerIndex];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
|
||||
|
||||
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") return -1;
|
||||
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
|
||||
}
|
||||
|
||||
function endsAtFinalPromptBoundary(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
}
|
||||
|
||||
const COMMON_SHELL_COMMANDS = new Set([
|
||||
"alias",
|
||||
"awk",
|
||||
"az",
|
||||
"brew",
|
||||
"bun",
|
||||
"bundle",
|
||||
"cargo",
|
||||
"cat",
|
||||
"cd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"code",
|
||||
"composer",
|
||||
"cp",
|
||||
"curl",
|
||||
"docker",
|
||||
"echo",
|
||||
"emacs",
|
||||
"env",
|
||||
"export",
|
||||
"find",
|
||||
"gcloud",
|
||||
"gh",
|
||||
"git",
|
||||
"go",
|
||||
"gradle",
|
||||
"grep",
|
||||
"helm",
|
||||
"java",
|
||||
"javac",
|
||||
"kubectl",
|
||||
"less",
|
||||
"ls",
|
||||
"make",
|
||||
"mkdir",
|
||||
"mvn",
|
||||
"mv",
|
||||
"nano",
|
||||
"node",
|
||||
"npm",
|
||||
"npx",
|
||||
"nvim",
|
||||
"php",
|
||||
"pip",
|
||||
"pip3",
|
||||
"pnpm",
|
||||
"printf",
|
||||
"python",
|
||||
"python3",
|
||||
"rails",
|
||||
"rm",
|
||||
"rsync",
|
||||
"ruby",
|
||||
"rustc",
|
||||
"scp",
|
||||
"screen",
|
||||
"sed",
|
||||
"ssh",
|
||||
"sudo",
|
||||
"tail",
|
||||
"tar",
|
||||
"terraform",
|
||||
"tmux",
|
||||
"touch",
|
||||
"uv",
|
||||
"vi",
|
||||
"vim",
|
||||
"yarn",
|
||||
]);
|
||||
|
||||
function getLeadingShellCommandWord(text: string): string | null {
|
||||
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function startsWithCommonShellCommand(text: string): boolean {
|
||||
const command = getLeadingShellCommandWord(text);
|
||||
return command !== null && COMMON_SHELL_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
function isCompleteSpecificPrompt(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return (
|
||||
promptBoundary >= 0 &&
|
||||
promptText.slice(promptBoundary).trim().length === 0 &&
|
||||
isSpecificShellPromptCandidate(promptText) &&
|
||||
!isEmbeddedPromptMarker(promptText, promptBoundary)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
|
||||
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
|
||||
const trimmedStart = text.trimStart();
|
||||
if (startsWithCommonShellCommand(trimmedStart)) return true;
|
||||
|
||||
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
|
||||
return startsWithCommonShellCommand(afterDecoration);
|
||||
}
|
||||
|
||||
function isSingleBareDirectoryExtra(extra: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
|
||||
}
|
||||
|
||||
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
|
||||
return /^\s+\S+\s+$/.test(extra);
|
||||
}
|
||||
|
||||
type PromptDecorationReconcileOptions = {
|
||||
allowSingleWordCommandDirectory?: boolean;
|
||||
};
|
||||
|
||||
function canTreatCommonCommandNameAsThemedDirectory(
|
||||
extra: string,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
const trimmedInput = typedInput.trim();
|
||||
return (
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
(
|
||||
/\s/.test(trimmedInput) ||
|
||||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
|
||||
(
|
||||
options.allowSingleWordCommandDirectory === true &&
|
||||
hasExplicitThemedDirectorySpacing(extra)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canReconcilePromptDecoration(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
if (
|
||||
!prompt.isAtPrompt ||
|
||||
!typedInput ||
|
||||
prompt.userInput.length <= typedInput.length ||
|
||||
!prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
|
||||
if (
|
||||
isThemedPromptText(prompt.promptText) &&
|
||||
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatePromptText = prompt.promptText + extra;
|
||||
const promptEndsAtBoundary =
|
||||
endsAtFinalPromptBoundary(candidatePromptText) &&
|
||||
isSpecificShellPromptCandidate(candidatePromptText);
|
||||
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
|
||||
}
|
||||
|
||||
function alignTypedInputFromCursorPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
cursorPrefix: string | null,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!cursorPrefix) return null;
|
||||
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
|
||||
|
||||
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
|
||||
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function canUseReliablePromptPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): boolean {
|
||||
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typedBuffer.length <= raw.userInput.length) return false;
|
||||
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
|
||||
allowShortEcho: allowsShortPromptEcho(raw.promptText),
|
||||
});
|
||||
}
|
||||
|
||||
function isLikelyBareMongoPromptName(promptName: string): boolean {
|
||||
return /^(?:test|admin|local|config)$/i.test(promptName);
|
||||
}
|
||||
|
||||
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const promptName = trimmed.slice(0, -1).trim();
|
||||
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
|
||||
}
|
||||
|
||||
function endsWithStandardShellPrompt(promptText: string): boolean {
|
||||
const finalChar = promptText.trimEnd().at(-1);
|
||||
return finalChar === "$" || finalChar === "#" || finalChar === "%";
|
||||
}
|
||||
|
||||
function allowsShortPromptEcho(promptText: string): boolean {
|
||||
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
|
||||
}
|
||||
|
||||
function isReliableTypedPrefix(
|
||||
echoedInput: string,
|
||||
typedBuffer: string,
|
||||
options: { allowShortEcho?: boolean } = {},
|
||||
): boolean {
|
||||
if (!typedBuffer.startsWith(echoedInput)) return false;
|
||||
if (
|
||||
options.allowShortEcho &&
|
||||
typedBuffer.trim().length <= 2 &&
|
||||
echoedInput.trim().length >= 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
|
||||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4)
|
||||
);
|
||||
}
|
||||
|
||||
function withTypedUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
...prompt,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function alignThemedDecorationWithPartialEcho(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
|
||||
|
||||
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
|
||||
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!raw.userInput.endsWith(echoedInput)) continue;
|
||||
|
||||
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
|
||||
if (extra.length === 0) continue;
|
||||
const hasReliableThemedDirectoryPrefix =
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
hasExplicitThemedDirectorySpacing(extra) &&
|
||||
typedBuffer.trim().length <= 3 &&
|
||||
echoedInput.trim().length >= 1;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...raw,
|
||||
userInput: extra + typedBuffer,
|
||||
cursorOffset: extra.length + typedBuffer.length,
|
||||
};
|
||||
if (
|
||||
!hasReliableThemedDirectoryPrefix &&
|
||||
!isReliableTypedPrefix(echoedInput, typedBuffer)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) continue;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText: raw.promptText + extra,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
@@ -88,15 +621,26 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const lineText = line.translateToString(false);
|
||||
|
||||
// Check for non-prompt patterns (pagers, editors, etc.)
|
||||
for (const pattern of NON_PROMPT_PATTERNS) {
|
||||
if (pattern.test(lineText)) return NO_PROMPT;
|
||||
if (isNonPromptLine(lineText)) return NO_PROMPT;
|
||||
if (line.isWrapped) {
|
||||
const wrappedPrefix = getWrappedCursorPrefix(term);
|
||||
if (wrappedPrefix && isNonPromptLine(wrappedPrefix)) return NO_PROMPT;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
// Try to find the prompt boundary on the current line
|
||||
const promptEnd = findPromptBoundary(lineText);
|
||||
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
|
||||
// Try to find the prompt boundary on the current line. xterm buffer rows are
|
||||
// padded with blank cells; when the cursor is at the visible row end, scan
|
||||
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
|
||||
// trailing space. If there is command text to the right of the cursor, keep
|
||||
// the full line so "$" / ">" inside mid-line edits are validated against
|
||||
// their real following character.
|
||||
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Use cursor position to determine actual input length — don't trim trailing
|
||||
@@ -125,6 +669,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (promptLine) {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
@@ -139,6 +684,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
|
||||
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
|
||||
const cursorOffset = userInput.length;
|
||||
if (isNonPromptLine(promptText + userInput)) return NO_PROMPT;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
@@ -165,6 +711,56 @@ function isPuaChar(ch: string): boolean {
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
}
|
||||
|
||||
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
|
||||
if (boundary <= 0) return -1;
|
||||
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
|
||||
if (markerIndex <= 0) return false;
|
||||
|
||||
const marker = lineText[markerIndex];
|
||||
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
|
||||
|
||||
const prev = lineText[markerIndex - 1];
|
||||
return !/[\s~:\])}]/.test(prev);
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
|
||||
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
|
||||
}
|
||||
|
||||
function canSupersedeThemedPromptBoundary(
|
||||
lineText: string,
|
||||
previousBoundary: number,
|
||||
markerIndex: number,
|
||||
): boolean {
|
||||
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
|
||||
|
||||
const rawBetween = lineText.slice(previousBoundary, markerIndex);
|
||||
const between = rawBetween.trim();
|
||||
return (
|
||||
between.length === 0 ||
|
||||
isPromptPathDecoration(between) ||
|
||||
isPromptStatusText(between) ||
|
||||
(
|
||||
/^\s/.test(rawBetween) &&
|
||||
isPromptBareDirectoryText(between)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
|
||||
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "›";
|
||||
}
|
||||
|
||||
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
|
||||
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
|
||||
if (markerIndex <= 0) return false;
|
||||
if (lineText[markerIndex - 1] !== " ") return false;
|
||||
return lineText[markerIndex + 1] === " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
|
||||
@@ -193,6 +789,15 @@ function findPromptBoundary(lineText: string): number {
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
if (
|
||||
(ch === ">" || ch === "›") &&
|
||||
lastStandardBoundary >= 0 &&
|
||||
/\s/.test(lineText.slice(0, i).trim()) &&
|
||||
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
|
||||
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Must be followed by a space or end-of-line.
|
||||
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
@@ -252,6 +857,31 @@ function findPromptBoundary(lineText: string): number {
|
||||
// Record this as a candidate boundary. A standard shell prompt terminator
|
||||
// is more reliable than a later Powerline/Nerd Font glyph in command text.
|
||||
const boundary = nextChar === " " ? i + 2 : i + 1;
|
||||
const candidatePromptText = lineText.slice(0, boundary);
|
||||
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
|
||||
continue;
|
||||
}
|
||||
if (isStandard && lastStandardBoundary >= 0) {
|
||||
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
|
||||
lineText,
|
||||
lastStandardBoundary,
|
||||
getBoundaryMarkerIndex(lineText, boundary),
|
||||
);
|
||||
const canSupersedePreviousBoundary =
|
||||
canPromptMarkerSupersedePreviousBoundary(ch) &&
|
||||
(
|
||||
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
|
||||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
|
||||
themedPromptCanSupersede
|
||||
) &&
|
||||
(
|
||||
themedPromptCanSupersede ||
|
||||
isSpecificShellPromptCandidate(candidatePromptText, {
|
||||
allowGreaterThanTerminator: ch === ">" || ch === "›",
|
||||
})
|
||||
);
|
||||
if (!canSupersedePreviousBoundary) continue;
|
||||
}
|
||||
if (isStandard) {
|
||||
lastStandardBoundary = boundary;
|
||||
} else {
|
||||
@@ -291,6 +921,11 @@ export function reconcilePromptWithTypedInput(
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
if (!canReconcilePromptDecoration(prompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return prompt;
|
||||
}
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
@@ -302,6 +937,36 @@ export function reconcilePromptWithTypedInput(
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function reconcilePromptWithExternalCommand(
|
||||
prompt: PromptDetectionResult,
|
||||
command: string,
|
||||
): PromptDetectionResult | null {
|
||||
const typedInput = command.trim();
|
||||
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...prompt,
|
||||
userInput: `${prompt.userInput}${typedInput}`,
|
||||
cursorOffset: prompt.userInput.length + typedInput.length,
|
||||
};
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = syntheticPrompt.userInput.slice(
|
||||
0,
|
||||
syntheticPrompt.userInput.length - typedInput.length,
|
||||
);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
@@ -312,13 +977,11 @@ export function reconcilePromptWithTypedInput(
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable AND the raw detected prompt still looks like the same shell
|
||||
* line. When the raw buffer has either over-captured prompt chrome
|
||||
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
|
||||
* shell echo/render is lagging behind local keystrokes
|
||||
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
|
||||
* Otherwise the buffer is ignored and the raw detector result passes
|
||||
* through.
|
||||
* reliable and it can be reconciled with the live line. Exact raw
|
||||
* matches are safe, over-captured prompt chrome can be moved back into
|
||||
* promptText, and no-space prompts can be inferred from the cursor line
|
||||
* when the inferred prompt still looks like a shell prompt. Otherwise
|
||||
* the buffer is ignored and the raw detector result passes through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
@@ -327,57 +990,40 @@ export function getAlignedPrompt(
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
|
||||
if (!typedReliable || typedBuffer.length === 0) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
return {
|
||||
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
|
||||
return {
|
||||
prompt: replacePromptUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const cursorLinePrefix = getCursorLinePrefix(term);
|
||||
if (cursorLinePrefix?.endsWith(typedBuffer)) {
|
||||
const promptText = cursorLinePrefix.slice(0, cursorLinePrefix.length - typedBuffer.length);
|
||||
if (promptText.length > 0) {
|
||||
|
||||
if (raw.isAtPrompt) {
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
|
||||
if (prompt === raw) return { prompt: raw, alignedTyped: null };
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
prompt,
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
|
||||
if (themedDecorationAlignment) return themedDecorationAlignment;
|
||||
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
|
||||
return {
|
||||
prompt: withTypedUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
const cursorPrefixCandidates = [
|
||||
getWrappedCursorPrefix(term),
|
||||
getCursorLinePrefix(term),
|
||||
];
|
||||
for (const cursorPrefix of cursorPrefixCandidates) {
|
||||
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
|
||||
if (aligned) return aligned;
|
||||
}
|
||||
|
||||
return findPromptBoundary(lineText) >= 0;
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
@@ -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 ".";
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { GhostTextAddon } from "./GhostTextAddon";
|
||||
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
|
||||
import {
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { shellEscape } from "./completionEngine";
|
||||
@@ -19,6 +24,7 @@ import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -107,6 +113,96 @@ export interface TerminalAutocompleteHandle {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
function hasStandardShellPromptTerminator(promptText: string): boolean {
|
||||
return /[$#%>]$/.test(promptText.trimEnd());
|
||||
}
|
||||
|
||||
function isSingleThemedPromptTerminator(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length !== 1) return false;
|
||||
const code = trimmed.charCodeAt(0);
|
||||
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
|
||||
}
|
||||
|
||||
function isThemedPromptPathToken(token: string): boolean {
|
||||
return (
|
||||
token === "~" ||
|
||||
token.startsWith("~/") ||
|
||||
token.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token) ||
|
||||
token.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
|
||||
const hasThemedPromptMarker =
|
||||
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
|
||||
Array.from(prompt.promptText).some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
});
|
||||
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
|
||||
return false;
|
||||
}
|
||||
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
|
||||
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
|
||||
return (
|
||||
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
|
||||
/\S+\s+\S/.test(prompt.userInput)
|
||||
);
|
||||
}
|
||||
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
|
||||
}
|
||||
|
||||
export function getCommandToRecordOnEnter(
|
||||
livePrompt: PromptDetectionResult,
|
||||
alignedTyped: string | null,
|
||||
typedBuffer: string,
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
|
||||
if (reliableTypedCommand) {
|
||||
const reconciledPrompt = reconcilePromptWithExternalCommand(
|
||||
livePrompt,
|
||||
reliableTypedCommand,
|
||||
);
|
||||
if (reconciledPrompt) return reliableTypedCommand;
|
||||
}
|
||||
|
||||
const liveCommand = livePrompt.userInput.trim();
|
||||
if (!liveCommand && reliableTypedCommand) {
|
||||
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
|
||||
? null
|
||||
: reliableTypedCommand;
|
||||
}
|
||||
if (!liveCommand) return null;
|
||||
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
const liveInputMayIncludePromptDecoration =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
liveCommand !== typedBuffer.trim() &&
|
||||
liveCommand.endsWith(typedBuffer.trim());
|
||||
if (liveInputMayIncludePromptDecoration) return null;
|
||||
|
||||
const liveInputMayBeLagging =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
typedBuffer.length > livePrompt.userInput.length &&
|
||||
typedBuffer.startsWith(livePrompt.userInput);
|
||||
if (liveInputMayBeLagging) return null;
|
||||
|
||||
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
export function useTerminalAutocomplete(
|
||||
options: UseTerminalAutocompleteOptions,
|
||||
): TerminalAutocompleteHandle {
|
||||
@@ -158,6 +254,10 @@ export function useTerminalAutocomplete(
|
||||
const fetchVersionRef = useRef(0);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -275,6 +375,7 @@ export function useTerminalAutocomplete(
|
||||
return listDirectoryEntries(dirPath, {
|
||||
sessionId: sessionIdRef.current,
|
||||
protocol: protocolRef.current,
|
||||
os: hostOsRef.current,
|
||||
foldersOnly: false,
|
||||
limit: 50,
|
||||
});
|
||||
@@ -308,7 +409,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;
|
||||
@@ -436,6 +541,41 @@ export function useTerminalAutocomplete(
|
||||
});
|
||||
}, [termRef]);
|
||||
|
||||
/**
|
||||
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
|
||||
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
|
||||
*/
|
||||
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term, typedInputBufferRef.current, typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const parsed = parseCommandLine(prompt.userInput);
|
||||
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
|
||||
+ (parsed.wordIndex > 0 ? " " : "");
|
||||
const currentToken = parsed.currentWord;
|
||||
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
|
||||
? currentToken[0] : "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name : shellEscape(entry.name);
|
||||
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Handle selecting a file/directory from any sub-dir panel.
|
||||
* Builds the full path from the panel stack and replaces the current input. */
|
||||
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
|
||||
@@ -566,6 +706,9 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
@@ -638,29 +781,21 @@ export function useTerminalAutocomplete(
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const typedBuffer = typedInputBufferRef.current;
|
||||
const typedBufferReliable = typedBufferReliableRef.current;
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (livePrompt.isAtPrompt) {
|
||||
// alignedTyped is only non-null when the buffer is reliable
|
||||
// AND matches the live line's tail — that single signal
|
||||
// covers both the robbyrussell "~ " case (#806) and the
|
||||
// stale-buffer cases from out-of-band pastes / history
|
||||
// recall (#814 P1/P2). When it's null we fall back to the
|
||||
// reconciled livePrompt.userInput, which for paste-bypass
|
||||
// scenarios lands on pre-PR behavior (no regression).
|
||||
if (alignedTyped && alignedTyped.trim()) {
|
||||
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
|
||||
} else if (livePrompt.userInput.trim()) {
|
||||
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
|
||||
// Only fall back to the cached prompt when we have no live
|
||||
// reading at all — guards against recording during interactive
|
||||
// prompts where detectPrompt correctly bails out.
|
||||
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
|
||||
const commandToRecord = getCommandToRecordOnEnter(
|
||||
livePrompt,
|
||||
alignedTyped,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (commandToRecord) {
|
||||
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
@@ -784,6 +919,10 @@ export function useTerminalAutocomplete(
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
@@ -963,10 +1102,11 @@ export function useTerminalAutocomplete(
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
@@ -995,8 +1135,10 @@ export function useTerminalAutocomplete(
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
@@ -1052,39 +1194,37 @@ export function useTerminalAutocomplete(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation
|
||||
if (e.key === "ArrowUp") {
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
|
||||
renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
if (s.selectedIndex >= 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
insertSuggestion(selected, true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1093,8 +1233,12 @@ export function useTerminalAutocomplete(
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1104,6 +1248,36 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
*/
|
||||
const renderPreviewSelection = useCallback((index: number) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const baseline = previewBaselineRef.current;
|
||||
const candidate =
|
||||
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput,
|
||||
candidate,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = candidate;
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/**
|
||||
* Insert a suggestion into the terminal.
|
||||
* @param execute If true, also sends \r to execute the command.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
45
components/terminal/ghostTextConsistency.test.ts
Normal file
45
components/terminal/ghostTextConsistency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
|
||||
|
||||
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
|
||||
});
|
||||
|
||||
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
|
||||
// Tracked is one char behind what the device actually shows.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
|
||||
});
|
||||
|
||||
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
|
||||
// The tracked input hasn't been fully echoed yet — reality being behind
|
||||
// never corrupts, so the ghost must stay.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
|
||||
});
|
||||
|
||||
test("ignores trailing whitespace after the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
|
||||
});
|
||||
|
||||
test("hides when untracked non-space input follows the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
|
||||
});
|
||||
|
||||
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
|
||||
// Prompt contains 'int'; the real typed 'int' is the one at the end.
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
|
||||
});
|
||||
|
||||
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
|
||||
});
|
||||
|
||||
test("skips single-character input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
|
||||
});
|
||||
|
||||
test("returns false when the tracked input isn't on the line yet (latency)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
45
components/terminal/livePreviewSequence.test.ts
Normal file
45
components/terminal/livePreviewSequence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
|
||||
|
||||
test("appends only the tail when the candidate continues the current line", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
|
||||
"cker",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty when the line already equals the candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
|
||||
"\x15df",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears when switching to a shorter prefix candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
|
||||
"\x15docker",
|
||||
);
|
||||
});
|
||||
|
||||
test("reverting to the typed baseline clears then rewrites the baseline", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
|
||||
"\x15do",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
|
||||
"\b\b\bxy",
|
||||
);
|
||||
});
|
||||
234
components/terminal/replaySafeTerminalLog.test.ts
Normal file
234
components/terminal/replaySafeTerminalLog.test.ts
Normal 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);
|
||||
});
|
||||
427
components/terminal/replaySafeTerminalLog.ts
Normal file
427
components/terminal/replaySafeTerminalLog.ts
Normal 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();
|
||||
}
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 ?? false;
|
||||
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 {
|
||||
@@ -294,6 +377,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const scheduleStartupCommand = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
id: string,
|
||||
onSettled?: () => void,
|
||||
): (() => void) | undefined => {
|
||||
@@ -311,6 +395,9 @@ const scheduleStartupCommand = (
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`, {
|
||||
automated: true,
|
||||
});
|
||||
if (!ctx.noAutoRun) {
|
||||
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, commandToRun);
|
||||
}
|
||||
onSettled?.();
|
||||
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
@@ -392,7 +479,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 +501,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 +544,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 +564,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 +652,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 +671,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;
|
||||
}
|
||||
@@ -789,7 +878,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
|
||||
// Run OS detection only after successful connection. Mint a fresh
|
||||
// token for this specific connection attempt and register it as
|
||||
@@ -823,7 +912,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 +924,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 +932,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 +940,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 +976,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;
|
||||
}
|
||||
@@ -903,7 +992,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.sessionId,
|
||||
() => {
|
||||
disposeAutoLoginListener();
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = scheduleStartupCommand(ctx, term, telnetSessionId, () => {
|
||||
cancelPendingStartupCommand = undefined;
|
||||
disposeAutoLoginCancelListener();
|
||||
});
|
||||
@@ -928,24 +1017,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 +1042,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 +1050,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");
|
||||
};
|
||||
|
||||
@@ -1070,11 +1159,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
`\r\n[Mosh session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
|
||||
});
|
||||
|
||||
scheduleStartupCommand(ctx, id);
|
||||
scheduleStartupCommand(ctx, term, id);
|
||||
} 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 +1171,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 +1224,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 +1251,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 +1260,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 +1287,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 +1298,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");
|
||||
}
|
||||
};
|
||||
|
||||
396
components/terminal/runtime/createXTermRuntime.test.ts
Normal file
396
components/terminal/runtime/createXTermRuntime.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||||
|
||||
function createFakeTerm(lineText = "$ echo ok", 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
});
|
||||
|
||||
assert.equal(commandBufferRef.current, "");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution caches the current prompt instead of prompt-like command text", () => {
|
||||
const promptState = createPromptLineBreakState();
|
||||
const commandBufferRef = { current: "echo > out" };
|
||||
|
||||
recordTerminalCommandExecution("echo > out", {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
}, createFakeTerm("$ echo > out") as never);
|
||||
|
||||
assert.equal(promptState.lastPromptText, "$ ");
|
||||
assert.equal(promptState.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("command execution does not write interactive program input to shell history", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, "", lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record interactive input before echo appears", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "rs.status()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> ", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution does not record wrapped interactive program input", () => {
|
||||
const cases = [
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
||||
const commands = ["deploy", "exit", "help", "show dbs"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help" },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs" },
|
||||
{ rows: ["admin> ex", "it"], command: "exit" },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], rows[0]);
|
||||
assert.equal(commandBufferRef.current, "", rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records short commands when standard prompt echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls" },
|
||||
{ lineText: "prod> l", command: "ls" },
|
||||
{ lineText: "prod.web> l", command: "ls" },
|
||||
{ lineText: "user@host:~$ l", command: "ls" },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls" },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls" },
|
||||
{ lineText: "➜ git l", command: "ls" },
|
||||
{ lineText: "➜ git np", command: "npm" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from themed bare directory prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const promptState = createPromptLineBreakState();
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef: { current: promptState },
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
assert.equal(promptState.lastPromptText, promptText, lineText);
|
||||
assert.equal(promptState.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution still records host-style greater-than prompts", () => {
|
||||
const prompts = [
|
||||
"prod-web> ",
|
||||
"prod> ",
|
||||
"prod.web> ",
|
||||
"server> ",
|
||||
"staging> ",
|
||||
"webdb> ",
|
||||
"prod.db> ",
|
||||
];
|
||||
const commands = ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)", "db.stats()"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const command of commands) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(`${prompt}${command}`) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], `${prompt}${command}`);
|
||||
assert.equal(commandBufferRef.current, "", `${prompt}${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("command execution records direct sends from host-style greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const commandBufferRef = { current: command };
|
||||
const recorded: string[] = [];
|
||||
|
||||
recordTerminalCommandExecution(command, {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
},
|
||||
sessionId: "session-1",
|
||||
commandBufferRef,
|
||||
onCommandExecuted(nextCommand) {
|
||||
recorded.push(nextCommand);
|
||||
},
|
||||
}, createFakeTerm(lineText) as never);
|
||||
|
||||
assert.deepEqual(recorded, [command], lineText);
|
||||
assert.equal(commandBufferRef.current, "", lineText);
|
||||
}
|
||||
});
|
||||
@@ -43,11 +43,18 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
pasteTextIntoTerminal,
|
||||
shouldBroadcastTerminalUserInput,
|
||||
shouldSuppressTerminalInputScrollForUserPaste,
|
||||
} from "./terminalUserPaste";
|
||||
import {
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -73,6 +80,13 @@ export type XTermRuntime = {
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
/**
|
||||
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
|
||||
* next frame. No-op when the DOM renderer is active. Used to recover from the
|
||||
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
|
||||
* fall into after font changes or device pixel ratio changes.
|
||||
*/
|
||||
clearTextureAtlas: () => void;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -108,12 +122,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;
|
||||
@@ -150,6 +166,15 @@ const detectPlatform = (): XTermPlatform => {
|
||||
return "darwin";
|
||||
};
|
||||
|
||||
const csiParamsInclude = (
|
||||
params: readonly (number | number[])[],
|
||||
target: number,
|
||||
): boolean => params.some((param) => (
|
||||
Array.isArray(param)
|
||||
? param.includes(target)
|
||||
: param === target
|
||||
));
|
||||
|
||||
/**
|
||||
* Extract the primary font family from a CSS font-family string that may
|
||||
* include fallback fonts. `document.fonts.check` returns `false` when *any*
|
||||
@@ -369,6 +394,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
|
||||
// (claude code / gemini cli / opencode and other full-screen agents), font
|
||||
// changes, and device pixel ratio changes can leave that atlas in a corrupted
|
||||
// state that persists for the life of the terminal — the "garbled / 花屏"
|
||||
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
|
||||
// the atlas forces glyphs to re-rasterize at the correct scale on the next
|
||||
// frame. No-op for the DOM renderer.
|
||||
const clearWebglTextureAtlas = () => {
|
||||
if (!webglAddon) return;
|
||||
try {
|
||||
webglAddon.clearTextureAtlas();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] clearTextureAtlas failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Recover the renderer when the device pixel ratio changes (moving the window
|
||||
// between monitors with different DPI, or changing OS display scaling — a
|
||||
// common Windows trigger). matchMedia change does not fire a normal resize, so
|
||||
// this is needed in addition to the resize handling below.
|
||||
let stopDprWatch: () => void = () => {};
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
) {
|
||||
stopDprWatch = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: () => window.devicePixelRatio || 1,
|
||||
matchMedia: (query) => window.matchMedia(query),
|
||||
onChange: () => {
|
||||
clearWebglTextureAtlas();
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||||
let shouldOpen = false;
|
||||
@@ -410,6 +474,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 +570,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, term);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -536,6 +605,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (id) {
|
||||
pasteTextIntoTerminal(term, text, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
onPasteData: broadcastUserPasteData,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -547,6 +617,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (selection && id) {
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
|
||||
onPasteData: broadcastUserPasteData,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -599,6 +670,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 +686,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 +700,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 +714,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 +742,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, term);
|
||||
} else if (data === "\x7f" || data === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x03") {
|
||||
@@ -699,6 +778,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
const markCursorPositionReportRequest = (params: readonly (number | number[])[]): boolean => {
|
||||
if (csiParamsInclude(params, 6)) {
|
||||
markExpectedTerminalCursorPositionReport(term);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cursorPositionReportRequestDisposables = [
|
||||
term.parser.registerCsiHandler({ final: "n" }, markCursorPositionReportRequest),
|
||||
term.parser.registerCsiHandler({ prefix: "?", final: "n" }, markCursorPositionReportRequest),
|
||||
];
|
||||
|
||||
const writeKittyKeyboardReply = (payload: string) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
@@ -814,6 +905,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
@@ -832,10 +926,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
clearTextureAtlas: clearWebglTextureAtlas,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
kittyKeyboardDisposable.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
|
||||
941
components/terminal/runtime/promptLineBreak.test.ts
Normal file
941
components/terminal/runtime/promptLineBreak.test.ts
Normal file
@@ -0,0 +1,941 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
insertPromptLineBreakBeforePrompt,
|
||||
markPromptLineBreakCommandPending,
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
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 insert before an ambiguous prompt suffix inside output", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("world$ ", "$ ", 5),
|
||||
"world$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before prompt-like output after a line break", () => {
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt("\r\nhello$ ", "$ ", 0),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct root prompt in the same output chunk", () => {
|
||||
const prompt = "[root@iZwz9ftrhzy4b3hduolf6yZ ~]# ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct conda prompt in the same output chunk", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts before a distinct no-space root prompt in the same output chunk", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not insert before an already separated distinct prompt", () => {
|
||||
const prompt = "(base) rynn@aiserver:~$ ";
|
||||
|
||||
assert.equal(
|
||||
insertPromptLineBreakBeforePrompt(`file tail\r\n${prompt}`, prompt, 0),
|
||||
`file tail\r\n${prompt}`,
|
||||
);
|
||||
});
|
||||
|
||||
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, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("keeps waiting for the real prompt after an output suffix matches the prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"total $ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"total $ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("total $ ") as never, state);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("total $ ", 8) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps waiting after prompt-like output on a fresh line", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"\r\nhello$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\nhello$ ",
|
||||
);
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm("hello$ ") as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("hello$ ", 7) as never,
|
||||
"$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"\r\n$ ",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepares a same-chunk cat output break for a distinct prompt", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "(base) rynn@aiserver:~$ ";
|
||||
state.pendingCommand = true;
|
||||
|
||||
assert.equal(
|
||||
prepareTerminalDataForPromptLineBreak(
|
||||
createFakeTerm("", 0) as never,
|
||||
"without trailing newline(base) rynn@aiserver:~$ ",
|
||||
state,
|
||||
true,
|
||||
),
|
||||
"without trailing newline\r\n(base) rynn@aiserver:~$ ",
|
||||
);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt from typed command alignment", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${command.slice(0, -1)}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags by a word", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const command = "printf ok";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}printf `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a longer command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git `) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when command echo lags mid-word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const command = "git status";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}git st`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags near completion", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ git statu") as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches a standard prompt when command echo lags after a word boundary", () => {
|
||||
const cases = ["$ git ", "$ git st"];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"git status",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "$ ", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a standard prompt when short command echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", command: "ls" },
|
||||
{ lineText: "$ c", command: "cd" },
|
||||
{ lineText: "prod-web> l", command: "ls", promptText: "prod-web> " },
|
||||
{ lineText: "prod> l", command: "ls", promptText: "prod> " },
|
||||
{ lineText: "prod.web> l", command: "ls", promptText: "prod.web> " },
|
||||
{ lineText: "user@host:~$ l", command: "ls", promptText: "user@host:~$ " },
|
||||
{ lineText: "[user@host ~]$ l", command: "ls", promptText: "[user@host ~]$ " },
|
||||
{ lineText: "➜ netcatty $ l", command: "ls", promptText: "➜ netcatty $ " },
|
||||
{ lineText: "➜ git l", command: "ls", promptText: "➜ git " },
|
||||
{ lineText: "➜ git np", command: "npm", promptText: "➜ git " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText = "$ " } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "ls ", command: "ls -la" },
|
||||
{ echoedInput: "cd ", command: "cd /tmp" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches a no-space root prompt when a short command echo lags by one character", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "l", command: "ls" },
|
||||
{ echoedInput: "c", command: "cd" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(`${prompt}${echoedInput}`) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, command);
|
||||
assert.equal(state.pendingCommand, true, command);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ ls") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", command: "get file" },
|
||||
{ lineText: "ftp> ls", command: "ls" },
|
||||
{ lineText: "ghci> :t map", command: ":t map" },
|
||||
{ lineText: "node> .help", command: ".help" },
|
||||
{ lineText: "mongo> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "rs0 primary reporting> exit", command: "exit" },
|
||||
{ lineText: "irb(main):001> puts 1", command: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", command: "whereami" },
|
||||
{ lineText: "SQL> select 1", command: "select 1" },
|
||||
{ lineText: "cqlsh> select * from users", command: "select * from users" },
|
||||
{ lineText: "hive> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql> select 1", command: "select 1" },
|
||||
{ lineText: "jshell> /help", command: "/help" },
|
||||
{ lineText: " ...> System.out.println(1)", command: "System.out.println(1)" },
|
||||
{ lineText: "ksql> select 1", command: "select 1" },
|
||||
{ lineText: "trino> select 1", command: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", command: "select 1" },
|
||||
{ lineText: "presto> show catalogs", command: "show catalogs" },
|
||||
{ lineText: "presto:default> show tables", command: "show tables" },
|
||||
{ lineText: "duckdb> select 1", command: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", command: "ls" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", command: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", command: "select 1" },
|
||||
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", command: "select 1" },
|
||||
{ lineText: "spark-sql (default)> select 1", command: "select 1" },
|
||||
{ lineText: "test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> db", command: "db" },
|
||||
{ lineText: "test> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 primary test> db.stats()", command: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", command: "rs.status()" },
|
||||
{ lineText: "test> print(1)", command: "print(1)" },
|
||||
{ lineText: "test> 1 + 1", command: "1 + 1" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache wrapped common interactive program prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["sftp> get very-long-", "remote-file"], command: "get very-long-remote-file" },
|
||||
{ rows: ["node> console.", "log('ok')"], command: "console.log('ok')" },
|
||||
{ rows: ["mongo> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["cqlsh> select *", " from users"], command: "select * from users" },
|
||||
{ rows: ["jshell> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: [" ...> System.out.", "println(1)"], command: "System.out.println(1)" },
|
||||
{ rows: ["trino> select", " 1"], command: "select 1" },
|
||||
{ rows: ["trino:tpch> select", " 1"], command: "select 1" },
|
||||
{ rows: ["duckdb> select", " 1"], command: "select 1" },
|
||||
{ rows: ["cqlsh:cycling> select *", " from cyclist"], command: "select * from cyclist" },
|
||||
{ rows: ["hive (default)> select", " 1"], command: "select 1" },
|
||||
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], command: "select 1" },
|
||||
{ rows: ["test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], command: "db" },
|
||||
{ rows: ["rs0:PRIMARY> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], command: "const x = 1" },
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], command: "db.stats()" },
|
||||
{ rows: ["rs0 primary test> db.", "stats()"], command: "db.stats()" },
|
||||
{ rows: ["test> print", "(1)"], command: "print(1)" },
|
||||
{ rows: ["test> 1 ", "+ 1"], command: "1 + 1" },
|
||||
{ rows: ["admin@localhost:27017> db.", "stats()"], command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { rows, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches wrapped non-Mongo-looking default-name greater-than prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], command: "help", promptText: "test> " },
|
||||
{ rows: ["test> show ", "dbs"], command: "show dbs", promptText: "test> " },
|
||||
{ rows: ["admin> ex", "it"], command: "exit", promptText: "admin> " },
|
||||
{ rows: ["local> dep", "loy"], command: "deploy", promptText: "local> " },
|
||||
];
|
||||
|
||||
for (const { rows, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, rows[0]);
|
||||
assert.equal(state.pendingCommand, true, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live command suffix as prompt text", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("$ echo sudo") as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("does not cache host prompt command symbols as prompt text", () => {
|
||||
const prompt = "user@host:~$ ";
|
||||
const cases = [
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a themed prompt live command suffix as prompt text", () => {
|
||||
for (const lineText of [
|
||||
"➜ ~ echo sudo",
|
||||
"➜ echo sudo",
|
||||
"➜ make sudo",
|
||||
"➜ docker sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ~ echo # sudo",
|
||||
]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations from typed command alignment", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~/repo do", command: "do", promptText: "➜ ~/repo " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ + ls",
|
||||
command: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ + ",
|
||||
},
|
||||
{ lineText: "➜ netcatty ✗ $ ls", command: "ls", promptText: "➜ netcatty ✗ $ " },
|
||||
{ lineText: "➜ netcatty $ ls", command: "ls", promptText: "➜ netcatty $ " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed prompt decorations when command echo lags", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~ git ", command: "git status", promptText: "➜ ~ " },
|
||||
{ lineText: "➜ ~ git st", command: "git status", promptText: "➜ ~ " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git ",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git st",
|
||||
command: "git status",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches themed bare directory prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ netcatty ", command: "ls", promptText: "➜ netcatty " },
|
||||
{ lineText: "➜ git ", command: "npm", promptText: "➜ git " },
|
||||
{ lineText: "➜ git ", command: "git status", promptText: "➜ git " },
|
||||
{ lineText: "➜ make ", command: "sudo", promptText: "➜ make " },
|
||||
{ lineText: "➜ make ", command: "make build", promptText: "➜ make " },
|
||||
{ lineText: "➜ node ", command: "yarn", promptText: "➜ node " },
|
||||
];
|
||||
|
||||
for (const { lineText, command, promptText } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, promptText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache interactive prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", command: "const x = 1" },
|
||||
{ lineText: "test> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "test> ", command: "db" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", command: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", command: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("clears an old cached prompt when a direct send is interactive", () => {
|
||||
const state = createPromptLineBreakState();
|
||||
state.lastPromptText = "rs0 [direct: primary] reporting> ";
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm("rs0 [direct: primary] reporting> ") as never,
|
||||
"db.stats()",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "");
|
||||
assert.equal(state.pendingCommand, true);
|
||||
});
|
||||
|
||||
test("caches host-style greater-than prompts for direct sends before command echo", () => {
|
||||
const cases = [
|
||||
{ lineText: "server> ", command: "exit" },
|
||||
{ lineText: "staging> ", command: "show dbs" },
|
||||
{ lineText: "server> ", command: "db.stats()" },
|
||||
{ lineText: "webdb> ", command: "deploy" },
|
||||
{ lineText: "prod.db> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "deploy" },
|
||||
{ lineText: "test> ", command: "exit" },
|
||||
{ lineText: "test> ", command: "help" },
|
||||
{ lineText: "test> ", command: "show dbs" },
|
||||
{ lineText: "admin> ", command: "deploy" },
|
||||
];
|
||||
|
||||
for (const { lineText, command } of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
command,
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, lineText, lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a live path suffix as prompt text", () => {
|
||||
for (const lineText of ["$ cd ~/sudo", "$ cat > sudo", "$ echo path#sudo"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache a stale command from a standard prompt echo prefix", () => {
|
||||
for (const lineText of ["$ s", "$ su", "$ sud"]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache partial stale commands after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
for (const lineText of [`${prompt}s`, `${prompt}sud`]) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not cache stale command suffixes after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
`${prompt}cat > sudo`,
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo path#sudo`,
|
||||
`${prompt}> sudo`,
|
||||
`${prompt}# sudo`,
|
||||
`${prompt}% sudo`,
|
||||
`${prompt}$ sudo`,
|
||||
];
|
||||
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
|
||||
|
||||
for (const lineText of cases) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
markPromptLineBreakCommandPending(
|
||||
{ current: state },
|
||||
createFakeTerm(lineText) as never,
|
||||
"sudo",
|
||||
);
|
||||
|
||||
assert.equal(state.lastPromptText, "", lineText);
|
||||
assert.equal(state.pendingCommand, true, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs prompts that contain prompt-like symbols", () => {
|
||||
const prompts = [
|
||||
"user@host ~/foo# bar $ ",
|
||||
"user@host ~/foo# git $ ",
|
||||
"user@host ~/foo#git $ ",
|
||||
"root@host ~/foo# bar # ",
|
||||
"root@host ~/foo#bar # ",
|
||||
"fish@host ~/foo# bar % ",
|
||||
"fish@host ~/foo%bar % ",
|
||||
"user@host:~/foo# bar $ ",
|
||||
"user@host ~/repo # $ ",
|
||||
"➜ ~ $ ",
|
||||
"user@host ~/foo% bar $ ",
|
||||
"user@host ~/foo> bar $ ",
|
||||
"user@host ~/foo# bar> ",
|
||||
"user@host ~/foo# bar› ",
|
||||
"user@host ~/foo#bar> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(prompt) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt, prompt);
|
||||
assert.equal(state.pendingCommand, false, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncs a no-space root prompt without xterm row padding", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const state = createPromptLineBreakState();
|
||||
|
||||
syncPromptLineBreakState(createFakeTerm(`${prompt} `, prompt.length) as never, state);
|
||||
|
||||
assert.equal(state.lastPromptText, prompt);
|
||||
assert.equal(state.pendingCommand, 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, true);
|
||||
assert.equal(state.suppressNextPromptCache, false);
|
||||
});
|
||||
242
components/terminal/runtime/promptLineBreak.ts
Normal file
242
components/terminal/runtime/promptLineBreak.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
detectPrompt,
|
||||
getAlignedPrompt,
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
} 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 isDistinctPromptText = (promptText: string): boolean => {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length >= 8) return true;
|
||||
return trimmed.length >= 6 && /[@:\\/]/.test(trimmed);
|
||||
};
|
||||
|
||||
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>,
|
||||
term?: XTerm | null,
|
||||
command?: string,
|
||||
): void {
|
||||
if (!stateRef?.current) return;
|
||||
if (term) {
|
||||
const cachedFromCommand = command
|
||||
? cachePromptLineBreakPromptFromCommand(term, stateRef.current, command)
|
||||
: false;
|
||||
if (!cachedFromCommand) {
|
||||
cachePromptLineBreakPrompt(term, stateRef.current);
|
||||
}
|
||||
}
|
||||
stateRef.current.pendingCommand = true;
|
||||
stateRef.current.suppressNextPromptCache = false;
|
||||
}
|
||||
|
||||
function cachePromptLineBreakPromptFromCommand(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
command: string,
|
||||
): boolean {
|
||||
const trimmedCommand = command.trim();
|
||||
if (!state || trimmedCommand.length === 0) return false;
|
||||
|
||||
const aligned = getAlignedPrompt(term, trimmedCommand, true);
|
||||
if (!aligned.prompt.isAtPrompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
if (isNonPromptLine(`${aligned.prompt.promptText}${trimmedCommand}`)) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const prompt =
|
||||
aligned.alignedTyped === trimmedCommand
|
||||
? aligned.prompt
|
||||
: reconcilePromptWithExternalCommand(aligned.prompt, trimmedCommand);
|
||||
if (!prompt) {
|
||||
state.lastPromptText = "";
|
||||
state.suppressNextPromptCache = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cachePromptLineBreakPrompt(
|
||||
term: XTerm,
|
||||
state: PromptLineBreakState | undefined,
|
||||
): void {
|
||||
if (!state) return;
|
||||
|
||||
const prompt = detectPrompt(term);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
if (prompt.userInput.length > 0) return;
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.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) {
|
||||
if (endsWithLineBreak(prefixText)) return data;
|
||||
if (!isDistinctPromptText(promptText)) 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;
|
||||
const ambiguousPromptSuffix = hasAmbiguousPromptSuffix(data, state.lastPromptText);
|
||||
state.suppressNextPromptCache =
|
||||
nextData === data &&
|
||||
(ambiguousPromptSuffix ||
|
||||
(cursorXBeforeWrite > 0 && !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;
|
||||
return;
|
||||
}
|
||||
|
||||
state.lastPromptText = prompt.promptText;
|
||||
state.suppressNextPromptCache = false;
|
||||
state.pendingCommand = false;
|
||||
}
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type MediaQueryListLike,
|
||||
watchDevicePixelRatio,
|
||||
} from "./rendererDprWatch";
|
||||
|
||||
class FakeMediaQueryList implements MediaQueryListLike {
|
||||
readonly query: string;
|
||||
modernListeners: Array<() => void> = [];
|
||||
legacyListeners: Array<() => void> = [];
|
||||
private readonly supportsModern: boolean;
|
||||
|
||||
constructor(query: string, supportsModern = true) {
|
||||
this.query = query;
|
||||
this.supportsModern = supportsModern;
|
||||
if (!supportsModern) {
|
||||
// Strip the modern API to emulate legacy environments.
|
||||
this.addEventListener = undefined;
|
||||
this.removeEventListener = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners.push(listener);
|
||||
};
|
||||
|
||||
removeEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
addListener = (listener: () => void) => {
|
||||
this.legacyListeners.push(listener);
|
||||
};
|
||||
|
||||
removeListener = (listener: () => void) => {
|
||||
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
trigger() {
|
||||
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
|
||||
}
|
||||
|
||||
get listenerCount() {
|
||||
return this.modernListeners.length + this.legacyListeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(initialDpr: number, supportsModern = true) {
|
||||
let dpr = initialDpr;
|
||||
const created: FakeMediaQueryList[] = [];
|
||||
return {
|
||||
created,
|
||||
getDevicePixelRatio: () => dpr,
|
||||
matchMedia: (query: string) => {
|
||||
const mql = new FakeMediaQueryList(query, supportsModern);
|
||||
created.push(mql);
|
||||
return mql;
|
||||
},
|
||||
setDpr: (value: number) => {
|
||||
dpr = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registers a change listener for the current devicePixelRatio", () => {
|
||||
const env = makeEnv(1);
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(env.created.length, 1);
|
||||
assert.equal(env.created[0].query, "(resolution: 1dppx)");
|
||||
assert.equal(env.created[0].listenerCount, 1);
|
||||
});
|
||||
|
||||
test("invokes onChange when the media query reports a change", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("re-registers for the new ratio so subsequent changes still fire", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(env.created.length, 2);
|
||||
assert.equal(env.created[1].query, "(resolution: 2dppx)");
|
||||
// The stale listener must be detached so it cannot double-fire.
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
|
||||
env.setDpr(3);
|
||||
env.created[1].trigger();
|
||||
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("cleanup stops further onChange callbacks", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
|
||||
const env = makeEnv(1, /* supportsModern */ false);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.created[0].legacyListeners.length, 1);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 1);
|
||||
|
||||
stop();
|
||||
// After cleanup the most recently registered query has no listeners.
|
||||
const latest = env.created[env.created.length - 1];
|
||||
assert.equal(latest.listenerCount, 0);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user