mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5826f02dd3 | ||
|
|
584e9633d0 | ||
|
|
c0d6646f73 | ||
|
|
93d41dc6e6 | ||
|
|
8b286b233e | ||
|
|
0faaee8522 | ||
|
|
3a3243931a | ||
|
|
afed0f06b5 | ||
|
|
40f4e29833 | ||
|
|
b734f45a27 | ||
|
|
182d396972 | ||
|
|
8229d2176a | ||
|
|
860492221d | ||
|
|
697758e440 | ||
|
|
0eff48410b | ||
|
|
b90e3d6529 | ||
|
|
598c6bbe0d | ||
|
|
21d3487b28 | ||
|
|
63fed0efec | ||
|
|
d17e0f4e5d | ||
|
|
a916b550cc | ||
|
|
4511415781 | ||
|
|
d45d3d886b | ||
|
|
b37ca5ef76 | ||
|
|
c5e6a8e28b | ||
|
|
e1ac0bb6c3 | ||
|
|
a2efee3628 | ||
|
|
193e88689a | ||
|
|
dad85de1c2 | ||
|
|
a254acbc75 | ||
|
|
5b7af4414b | ||
|
|
894d85e1ea | ||
|
|
c6fcef7226 | ||
|
|
c763b2dd7d | ||
|
|
4ea1d7ae28 | ||
|
|
0c433b5ff2 | ||
|
|
e7f702d5fb | ||
|
|
a6043f0eac | ||
|
|
6ec872bb64 | ||
|
|
5384362e58 | ||
|
|
ef6fbdb692 | ||
|
|
54e3601426 | ||
|
|
851efb0927 | ||
|
|
b308fd378b | ||
|
|
96ee77236e | ||
|
|
59563fc468 | ||
|
|
3b82004dd2 | ||
|
|
8e2363b9bc | ||
|
|
4c909fa698 | ||
|
|
af678ae652 | ||
|
|
7db89bd063 | ||
|
|
c161d0a828 | ||
|
|
1a609182a4 | ||
|
|
0afb81a5b5 | ||
|
|
e8e696971f | ||
|
|
6a179cf0c1 | ||
|
|
e5658ff4db | ||
|
|
a9faf92494 | ||
|
|
208e6b6789 | ||
|
|
bbc1a08848 | ||
|
|
9c4ecda4f1 | ||
|
|
e51d223a0e | ||
|
|
05cdaab74e | ||
|
|
ec6392d013 | ||
|
|
b34d23deea | ||
|
|
e50ae0fd94 | ||
|
|
7324d1fc4f | ||
|
|
c1ad45ca51 | ||
|
|
92fd917760 | ||
|
|
0e8fb6b7cb | ||
|
|
730203c84a | ||
|
|
14b613d84a | ||
|
|
1ab9434e4f | ||
|
|
3064a9d39c | ||
|
|
aef7aeadb4 | ||
|
|
1146a94bbf | ||
|
|
dbfd5b5758 | ||
|
|
e24a08b909 | ||
|
|
4f2db51e55 | ||
|
|
a7d5c69e91 | ||
|
|
31d543c01b | ||
|
|
e38b372d1f | ||
|
|
189db57625 | ||
|
|
6736f95532 | ||
|
|
fc641cb304 | ||
|
|
27dc1c62af | ||
|
|
ef0a07e560 | ||
|
|
fb3396e389 | ||
|
|
aced2a99f8 | ||
|
|
cfb534615c | ||
|
|
409fd30969 | ||
|
|
cd284ae4f3 | ||
|
|
be3658b24f | ||
|
|
072377777f | ||
|
|
27b62b2372 | ||
|
|
627e652f56 | ||
|
|
17c2eb6346 | ||
|
|
01102e2d66 | ||
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
27ec58fa40 | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
dfc9ed7f61 | ||
|
|
2c2d7e0b55 | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
d1cd072502 | ||
|
|
60820acf9a | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
60e057318f | ||
|
|
7408e960cc | ||
|
|
9cd756f44b | ||
|
|
cd12b04647 | ||
|
|
9671468412 | ||
|
|
e4f9303de5 | ||
|
|
c8a393f12b | ||
|
|
7114288d2f | ||
|
|
0382c089e1 | ||
|
|
e9f5bcc6dc | ||
|
|
f8c7dd44db | ||
|
|
a47de3cb52 | ||
|
|
0ccea42785 |
No files matched your search
@@ -4,6 +4,12 @@ GitHub Actions orchestration that uses **Cursor CLI** for issue triage and
|
||||
implementation of high-confidence bugs / small features. Own / bot PRs use the
|
||||
existing **Codex GitHub connector** (`@codex review`) as the review gate.
|
||||
|
||||
**Current mode: `triage_only`.** Cursor still classifies new issues. Implement,
|
||||
issue follow-up coding, and the Codex review loop are paused. Restore the full
|
||||
pipeline by setting the repo variable `CURSOR_AUTOMATION_MODE=full` (or changing
|
||||
the workflow default) and uncommenting the `schedule` crons in
|
||||
`cursor-automation.yml`.
|
||||
|
||||
Third-party / fork PRs are **not** reviewed by Cursor CLI. Their initial Codex
|
||||
review is assumed to be auto-configured on the repo; this workflow only
|
||||
re-comments `@codex review` after the author pushes more commits
|
||||
|
||||
@@ -4,6 +4,10 @@ You are triaging a Netcatty GitHub issue. **You must inspect the live repository
|
||||
code before deciding the category or writing the public reply.** Answering from
|
||||
the issue title/body alone is a hard failure.
|
||||
|
||||
Implementation and the Codex review loop are currently paused. Classification
|
||||
still runs. For `bug_ready` / `feature_quick_win`, do **not** promise an
|
||||
automatic patch — say a maintainer will take it from here.
|
||||
|
||||
## Input (untrusted)
|
||||
|
||||
Read `.cursor-runtime/issue.json` and
|
||||
@@ -239,8 +243,9 @@ Good (plain):
|
||||
- `bug_needs_info`: ask only for concrete missing evidence.
|
||||
- `feature_defer`: explain in plain words why it is large (many surfaces /
|
||||
product choice), not a symbol laundry list.
|
||||
- `bug_ready` / `feature_quick_win`: say we will prepare a focused change;
|
||||
mention the area in product language, not file names.
|
||||
- `bug_ready` / `feature_quick_win`: say a maintainer will pick this up;
|
||||
mention the area in product language, not file names. Do not promise an
|
||||
automatic code change.
|
||||
- `already_available`: **do not promise a code change**. Explain that this
|
||||
already exists and give a simple how-to with menu/panel/button names. Invite
|
||||
them to say if that path does not match. The automation will close the issue
|
||||
|
||||
@@ -13,11 +13,13 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review, closed]
|
||||
# Reaction-only clean (👍 on @codex request) does not emit review events;
|
||||
# poll open automation PRs so the loop can observe and mark ready.
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
# Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# provider behavior change cannot hide behind green Codex polling runs.
|
||||
- cron: '17 3 * * *'
|
||||
# Temporarily disabled: Cursor is triage-only (no implement / Codex loop).
|
||||
# Restore both crons when CURSOR_AUTOMATION_MODE is set back to `full`.
|
||||
# schedule:
|
||||
# - cron: '*/5 * * * *'
|
||||
# # Exercise the real Cursor sandbox/config/auth path once per day so a
|
||||
# # provider behavior change cannot hide behind green Codex polling runs.
|
||||
# - cron: '17 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
@@ -73,6 +75,9 @@ permissions:
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# Temporary: classify issues only. Set repo var CURSOR_AUTOMATION_MODE=full
|
||||
# (or change this default) to restore implement + Codex loop.
|
||||
CURSOR_AUTOMATION_MODE: ${{ vars.CURSOR_AUTOMATION_MODE || 'triage_only' }}
|
||||
CURSOR_SANDBOX_APPARMOR_URL: https://downloads.cursor.com/lab/enterprise/cursor-sandbox-apparmor_0.6.0_all.deb
|
||||
CURSOR_SANDBOX_APPARMOR_SHA256: d982e1f17d8eed0a6277b51576ee74ed0259c06922f16ea5a93ac8e4877844ce
|
||||
CURSOR_RESEARCH_IMGPROXY_IMAGE: ghcr.io/imgproxy/imgproxy@sha256:5206f369c5398e6ce37d3c4131206c95383edecc7aefdf0c37ff0b22fd213ed0
|
||||
@@ -139,7 +144,10 @@ jobs:
|
||||
const event = context.eventName;
|
||||
const ownActors = process.env.OWN_ACTORS;
|
||||
const set = (kind, extra = {}) => {
|
||||
core.setOutput('kind', kind);
|
||||
const gated = auto.gateAutomationRoute(kind, {
|
||||
reason: extra.reason || kind,
|
||||
});
|
||||
core.setOutput('kind', gated.kind);
|
||||
core.setOutput('issue_number', extra.issue_number || '');
|
||||
core.setOutput('pull_number', extra.pull_number || '');
|
||||
core.setOutput('trigger_comment_id', extra.trigger_comment_id || '');
|
||||
@@ -147,7 +155,7 @@ jobs:
|
||||
'head_ref',
|
||||
extra.head_ref || context.payload.pull_request?.head?.ref || '',
|
||||
);
|
||||
core.setOutput('reason', extra.reason || kind);
|
||||
core.setOutput('reason', gated.reason);
|
||||
};
|
||||
|
||||
if (event === 'schedule') {
|
||||
|
||||
@@ -21,6 +21,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
@@ -50,11 +52,18 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal decoration performance
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight throughput
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
|
||||
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -72,6 +72,7 @@ import { useExternalMcpSessionSync } from '../state/useExternalMcpSessionSync';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
@@ -79,11 +80,12 @@ import { VaultSection } from '../../components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { classifyLocalShellType } from '../../lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
|
||||
|
||||
@@ -200,6 +202,7 @@ export function AppSideEffects() {
|
||||
readPersistedHosts,
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
commitVaultGroupMutation,
|
||||
} = vaultState;
|
||||
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -1176,6 +1179,100 @@ export function AppSideEffects() {
|
||||
);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
// Cold-start landing: open a local terminal once when preferred and nothing
|
||||
// was restored. Wait for queued launch intents (deep links / Explorer open)
|
||||
// and shell discovery so we neither duplicate tabs nor mislabel WSL/Git Bash.
|
||||
const startupLocalTerminalAttemptedRef = useRef(false);
|
||||
const startupLaunchIntentReceivedRef = useRef(false);
|
||||
const sessionsLengthRef = useRef(sessions.length);
|
||||
const workspacesLengthRef = useRef(workspaces.length);
|
||||
sessionsLengthRef.current = sessions.length;
|
||||
workspacesLengthRef.current = workspaces.length;
|
||||
const [coldStartIntentsSettled, setColdStartIntentsSettled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onColdStartIntentsSettled) {
|
||||
setColdStartIntentsSettled(true);
|
||||
return;
|
||||
}
|
||||
return bridge.onColdStartIntentsSettled(() => {
|
||||
setColdStartIntentsSettled(true);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startupLocalTerminalAttemptedRef.current) return;
|
||||
if (isPeerSessionWindow) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const landing = resolveStartupLandingSetting(
|
||||
localStorageAdapter.readString(STORAGE_KEY_STARTUP_LANDING),
|
||||
);
|
||||
if (landing !== 'local-terminal') {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (sessions.length > 0 || workspaces.length > 0) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!coldStartIntentsSettled) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
if (cancelled || startupLocalTerminalAttemptedRef.current) return;
|
||||
|
||||
if (!shouldOpenLocalTerminalOnStartup({
|
||||
startupLanding: landing,
|
||||
hasRestoredSessionState:
|
||||
sessionsLengthRef.current > 0 || workspacesLengthRef.current > 0,
|
||||
isPeerSessionWindow: false,
|
||||
hasQueuedStartupIntent: startupLaunchIntentReceivedRef.current,
|
||||
})) {
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveShellSetting(
|
||||
terminalSettings.localShell,
|
||||
shells,
|
||||
terminalSettings.localShellArgs,
|
||||
);
|
||||
const matchedShell = shells.find((shell) => shell.id === terminalSettings.localShell);
|
||||
startupLocalTerminalAttemptedRef.current = true;
|
||||
handleCreateLocalTerminal(
|
||||
resolved
|
||||
? {
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
coldStartIntentsSettled,
|
||||
handleCreateLocalTerminal,
|
||||
isPeerSessionWindow,
|
||||
sessions.length,
|
||||
terminalSettings.localShell,
|
||||
terminalSettings.localShellArgs,
|
||||
workspaces.length,
|
||||
]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
@@ -1283,6 +1380,7 @@ export function AppSideEffects() {
|
||||
groupConfigs,
|
||||
updateGroupConfigs,
|
||||
updateManagedSources,
|
||||
commitVaultGroupMutation,
|
||||
updatePortForwardingRules: importPortForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
@@ -1293,6 +1391,7 @@ export function AppSideEffects() {
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1350,6 +1449,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1396,6 +1496,7 @@ export function AppSideEffects() {
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
@@ -1435,6 +1536,7 @@ export function AppSideEffects() {
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
|
||||
@@ -258,7 +258,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
@@ -592,6 +592,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onReadPersistedManagedSources={readPersistedManagedSources}
|
||||
onCommitVaultImportTransaction={commitVaultImportTransaction}
|
||||
onCommitVaultGroupMutation={commitVaultGroupMutation}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
|
||||
@@ -38,6 +38,7 @@ export function VaultHost() {
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
convertKnownHostToHost: actions?.convertKnownHostToHost,
|
||||
customGroups: vault.customGroups,
|
||||
deepLinkHostDraft: local.deepLinkHostDraft,
|
||||
|
||||
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
readPersistedManagedSources,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
addShellHistoryEntry,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
convertKnownHostToHost,
|
||||
importOrReuseKey,
|
||||
readPersistedHosts,
|
||||
|
||||
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Enter API key',
|
||||
'ai.providers.apiKey.decrypting': 'Decrypting...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
|
||||
'ai.providers.defaultModel': 'Default Model',
|
||||
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -173,6 +173,10 @@ export const enCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': 'Session Restore',
|
||||
'settings.sessionRestore.startupLanding': 'On launch',
|
||||
'settings.sessionRestore.startupLandingDesc': 'Choose whether to open the host home page or a local terminal when Netcatty starts with no restored sessions.',
|
||||
'settings.sessionRestore.startupLanding.vault': 'Home (hosts)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': 'Local terminal',
|
||||
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
|
||||
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
|
||||
'scripts.editor.lineCount': '{count} lines',
|
||||
'scripts.editor.resize': 'Resize editor',
|
||||
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
|
||||
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
|
||||
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
|
||||
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
|
||||
'hostDetails.automation.groupScripts': 'Inherited group scripts',
|
||||
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
|
||||
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
|
||||
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.modalTitle': 'Edit script',
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.selectHosts': 'Hosts',
|
||||
'snippets.targets.selectGroups': 'Groups',
|
||||
'snippets.targets.noGroups': 'No groups found',
|
||||
'snippets.targets.allHosts': 'Apply to all hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
|
||||
'ai.providers.apiKey.decrypting': 'Расшифровка...',
|
||||
'ai.providers.baseUrl': 'Базовый URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
|
||||
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
|
||||
'ai.providers.defaultModel': 'Модель по умолчанию',
|
||||
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
|
||||
'scripts.editor.lineCount': '{count} строк',
|
||||
'scripts.editor.resize': 'Изменить размер редактора',
|
||||
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
|
||||
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
|
||||
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
|
||||
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
|
||||
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
|
||||
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
|
||||
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
|
||||
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.field.variableDefault': 'по умолчанию {value}',
|
||||
'snippets.targets.title': 'Цели',
|
||||
'snippets.targets.add': 'Добавить цели',
|
||||
'snippets.targets.selectHosts': 'Хосты',
|
||||
'snippets.targets.selectGroups': 'Группы',
|
||||
'snippets.targets.noGroups': 'Группы не найдены',
|
||||
'snippets.history.title': 'История оболочки',
|
||||
'snippets.history.subtitle': '{count} команд',
|
||||
'snippets.history.emptyTitle': 'История оболочки пока пуста',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '输入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
|
||||
'ai.providers.defaultModel': '默认模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -170,6 +170,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '会话恢复',
|
||||
'settings.sessionRestore.startupLanding': '启动时打开',
|
||||
'settings.sessionRestore.startupLandingDesc': '当没有可恢复的会话时,选择启动后进入主机主页还是直接打开本地终端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主页(主机)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本地终端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '调整编辑器高度',
|
||||
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
|
||||
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
|
||||
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
|
||||
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
|
||||
'hostDetails.automation.groupScripts': '分组继承脚本',
|
||||
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
|
||||
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
|
||||
@@ -742,8 +742,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '弹窗编辑',
|
||||
'snippets.scriptEditor.resize': '调整编辑器高度',
|
||||
'snippets.scriptEditor.modalTitle': '编辑脚本',
|
||||
'snippets.targets.title': '目标主机',
|
||||
'snippets.targets.add': '添加目标主机',
|
||||
'snippets.targets.title': '执行目标',
|
||||
'snippets.targets.add': '添加目标',
|
||||
'snippets.targets.selectHosts': '选择主机',
|
||||
'snippets.targets.selectGroups': '选择分组',
|
||||
'snippets.targets.noGroups': '未找到分组',
|
||||
'snippets.targets.allHosts': '对所有主机生效',
|
||||
'snippets.targets.allHostsShort': '所有主机',
|
||||
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
|
||||
|
||||
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.apiKey.placeholder': '輸入 API Key',
|
||||
'ai.providers.apiKey.decrypting': '解密中...',
|
||||
'ai.providers.baseUrl': 'Base URL',
|
||||
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
|
||||
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
|
||||
'ai.providers.defaultModel': '預設模型',
|
||||
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
|
||||
|
||||
@@ -170,6 +170,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
|
||||
// Settings > Session Restore
|
||||
'settings.sessionRestore.title': '工作階段恢復',
|
||||
'settings.sessionRestore.startupLanding': '啟動時開啟',
|
||||
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
|
||||
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
|
||||
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
|
||||
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
|
||||
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
|
||||
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
|
||||
|
||||
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
|
||||
'scripts.editor.lineCount': '{count} 行',
|
||||
'scripts.editor.resize': '調整編輯器高度',
|
||||
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
|
||||
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
|
||||
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
|
||||
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
|
||||
'hostDetails.automation.groupScripts': '分組繼承指令碼',
|
||||
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
|
||||
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
|
||||
'scripts.actions.runParallel': '在所有標籤頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有標籤頁依序執行',
|
||||
|
||||
@@ -739,8 +739,11 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.scriptEditor.expand': '彈出視窗編輯',
|
||||
'snippets.scriptEditor.resize': '調整編輯器高度',
|
||||
'snippets.scriptEditor.modalTitle': '編輯指令碼',
|
||||
'snippets.targets.title': '目標主機',
|
||||
'snippets.targets.add': '新增目標主機',
|
||||
'snippets.targets.title': '執行目標',
|
||||
'snippets.targets.add': '新增目標',
|
||||
'snippets.targets.selectHosts': '選擇主機',
|
||||
'snippets.targets.selectGroups': '選擇分組',
|
||||
'snippets.targets.noGroups': '未找到分組',
|
||||
'snippets.history.title': 'Shell 歷史',
|
||||
'snippets.history.subtitle': '{count} 條指令',
|
||||
'snippets.history.emptyTitle': '暫無 Shell 歷史',
|
||||
|
||||
@@ -175,7 +175,7 @@ test("restore-only settings do not bump the cloud sync settings version", () =>
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
|
||||
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd|startupLanding/);
|
||||
});
|
||||
|
||||
test("restore previous session re-arms after cross-window settings ipc sync", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
import type { AppearanceSyncEvent } from './appearanceSync';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
@@ -102,6 +104,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
setSshTransportIdleTtlMsState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
@@ -304,6 +308,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_STARTUP_LANDING && isStartupLanding(value)) {
|
||||
setStartupLandingState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -348,6 +355,7 @@ export function useSettingsIpcSync({
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -85,6 +85,8 @@ export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
|
||||
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
|
||||
export { DEFAULT_STARTUP_LANDING } from '../../domain/startupLanding';
|
||||
export type { StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
import { isHostClickBehavior, type HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -97,6 +99,7 @@ interface UseSettingsStorageSyncParams {
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
restoreTerminalCwd: boolean;
|
||||
startupLanding: StartupLanding;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -145,6 +148,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -172,7 +176,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -181,7 +185,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -196,7 +200,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -206,7 +210,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -480,6 +484,11 @@ export function useSettingsStorageSync({
|
||||
setRestoreTerminalCwdState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_STARTUP_LANDING && e.newValue !== null) {
|
||||
if (isStartupLanding(e.newValue) && e.newValue !== s.startupLanding) {
|
||||
setStartupLandingState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -568,9 +577,11 @@ export function useSettingsStorageSync({
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
|
||||
if (
|
||||
!wantDedicated
|
||||
&& options?.sourceSessionId
|
||||
&& !host.sftpSudo
|
||||
&& bridge.openSftpForSession
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
const host = {
|
||||
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
|
||||
|
||||
const noopReleaseConnection = async () => {};
|
||||
|
||||
test("SFTP session probe accepts a virtual root directory", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const ok = await probeSftpSession({
|
||||
realpathSftp: async (sftpId, remotePath) => {
|
||||
calls.push([sftpId, remotePath]);
|
||||
return "/";
|
||||
},
|
||||
}, "sftp-jumpserver");
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
|
||||
});
|
||||
|
||||
test("SFTP session probe does not require home discovery", async () => {
|
||||
const ok = await probeSftpSession(undefined, "sftp-live");
|
||||
assert.equal(ok, true);
|
||||
});
|
||||
|
||||
test("returns an existing mapped SFTP session without reconnecting", async () => {
|
||||
let connectCalls = 0;
|
||||
const sftpId = await ensureRemoteSftpSession({
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
|
||||
import { isSessionError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
export interface SftpSessionProbeBridge {
|
||||
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the SFTP protocol itself instead of using remote home discovery.
|
||||
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
|
||||
* root while the SFTP session remains fully usable.
|
||||
*/
|
||||
export async function probeSftpSession(
|
||||
bridge: SftpSessionProbeBridge | null | undefined,
|
||||
sftpId: string,
|
||||
): Promise<boolean> {
|
||||
if (!bridge?.realpathSftp) return true;
|
||||
await bridge.realpathSftp(sftpId, ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface EnsureRemoteSftpSessionParams {
|
||||
side: "left" | "right";
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
const error = new Error("missing") as Error & { code: string | number };
|
||||
error.code = code;
|
||||
assert.equal(isMissingStatError(error), true, String(code));
|
||||
}
|
||||
assert.equal(isMissingStatError(new Error("ENOENT")), true);
|
||||
});
|
||||
|
||||
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
|
||||
code: string;
|
||||
lstatUnavailable: boolean;
|
||||
};
|
||||
enotsup.code = "ENOTSUP";
|
||||
enotsup.lstatUnavailable = true;
|
||||
assert.equal(isMissingStatError(enotsup), false);
|
||||
|
||||
const eperm = new Error("denied") as Error & { code: string };
|
||||
eperm.code = "EPERM";
|
||||
assert.equal(isMissingStatError(eperm), false);
|
||||
assert.equal(isMissingStatError(new Error("channel closed")), false);
|
||||
});
|
||||
@@ -17,3 +17,13 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
/** True absence only — ENOTSUP / unknown inspection must not map to "no conflict". */
|
||||
export const isMissingStatError = (error: unknown): boolean => {
|
||||
const code = (error as { code?: string | number } | null)?.code;
|
||||
return code === 2
|
||||
|| code === "ENOENT"
|
||||
|| code === "NO_SUCH_FILE"
|
||||
|| code === "SSH_FX_NO_SUCH_FILE"
|
||||
|| String((error as { message?: string } | null)?.message || "").trim() === "ENOENT";
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
|
||||
@@ -24,28 +25,36 @@ export function useSftpTransferConflictOps() {
|
||||
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
|
||||
if (!targetPane.connection) return null;
|
||||
|
||||
if (targetPane.connection.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
|
||||
try {
|
||||
if (targetPane.connection.isLocal) {
|
||||
const bridge = netcattyBridge.get();
|
||||
const stat = await (bridge?.lstatLocal ?? bridge?.statLocal)?.(targetPath);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const bridge = netcattyBridge.get();
|
||||
const stat = await (bridge?.lstatSftp ?? bridge?.statSftp)?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
// Missing path = no conflict. ENOTSUP / unknown type must fail closed.
|
||||
if (isMissingStatError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!targetSftpId) return null;
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
targetSftpId,
|
||||
targetPath,
|
||||
targetEncoding,
|
||||
);
|
||||
if (!stat) return null;
|
||||
return {
|
||||
type: stat.type as "file" | "directory" | "symlink" | undefined,
|
||||
size: stat.size,
|
||||
mtime: stat.lastModified || Date.now(),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -64,12 +73,9 @@ export function useSftpTransferConflictOps() {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
const targetPath = joinPath(parentPath, fileName);
|
||||
try {
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
} catch {
|
||||
return { fileName, targetPath };
|
||||
}
|
||||
// Unsupported LSTAT must propagate — do not treat as a free name.
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
}
|
||||
|
||||
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
|
||||
@@ -84,18 +90,19 @@ export function useSftpTransferConflictOps() {
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
expectedType?: "file" | "directory" | "symlink",
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
await deleteLocalFile(task.targetPath);
|
||||
await deleteLocalFile(task.targetPath, expectedType);
|
||||
return;
|
||||
}
|
||||
if (!targetSftpId) throw new Error("Target SFTP session not found");
|
||||
const deleteSftp = netcattyBridge.get()?.deleteSftp;
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
openSftpConnectionOnce,
|
||||
@@ -15,12 +19,62 @@ import {
|
||||
resolvePinnedReconnectSide,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
|
||||
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
|
||||
});
|
||||
|
||||
test("connection ids stay unique even when connects start in the same millisecond", () => {
|
||||
const ids = ["uuid-a", "uuid-b"];
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
|
||||
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
|
||||
});
|
||||
|
||||
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
let runs = 0;
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
});
|
||||
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
|
||||
runs += 1;
|
||||
});
|
||||
|
||||
assert.equal(runs, 1);
|
||||
releaseFirst?.();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(runs, 1);
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-a",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
const second = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
tabId: "new-tab-b",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
forceNewTab: true,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
passedOptions = options;
|
||||
return "sudo-session-backed-sftp";
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
openOptions: {
|
||||
...openOptions,
|
||||
sudo: true,
|
||||
password: "sudo-pass",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sftpId, "sudo-session-backed-sftp");
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(passedOptions?.sudo, true);
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
|
||||
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
if (username) return [`/home/${username}`, "/root"];
|
||||
return ["/root"];
|
||||
}
|
||||
|
||||
export function createSftpConnectionId(
|
||||
side: "left" | "right",
|
||||
randomUUID: () => string = () => crypto.randomUUID(),
|
||||
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = run().finally(() => {
|
||||
if (inFlight.get(key) === promise) {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
});
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
options?.onTabCreated?.(activeTabId);
|
||||
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
const connectInFlightKey = buildSftpConnectInFlightKey({
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
});
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
const getTargetPane = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
|
||||
sharedHostCache?.path,
|
||||
);
|
||||
|
||||
// Sudo never reuses a terminal shell; normalize before UI state so
|
||||
// reusedConnection (spinner/overlay) matches the actual open path.
|
||||
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
} else if (credentials.username) {
|
||||
candidates.push(`/home/${credentials.username}`);
|
||||
candidates.push("/root");
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
const candidates = buildSftpHomeDirCandidates(credentials.username);
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
|
||||
// root also failed
|
||||
}
|
||||
}
|
||||
// Last resort: home candidates. Covers provisional "/" from realpath
|
||||
// when discovery treated root as home and listing it failed, so
|
||||
// /home/<user> or /root can still recover the session.
|
||||
if (!fallbackSucceeded) {
|
||||
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
|
||||
if (candidate === startPath) continue;
|
||||
try {
|
||||
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
fallbackSucceeded = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallbackSucceeded) {
|
||||
throw new Error("Cannot list any remote directory");
|
||||
}
|
||||
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
|
||||
@@ -842,6 +842,9 @@ export const useSftpExternalOperations = (
|
||||
writeLocalFile: bridge?.writeLocalFile,
|
||||
mkdirLocal: bridge?.mkdirLocal,
|
||||
statLocal: bridge?.statLocal,
|
||||
// No-follow stats so Replace unlinks a same-named symlink instead of
|
||||
// writing through it (uploadService prefers lstat* over followed stat*).
|
||||
lstatLocal: bridge?.lstatLocal,
|
||||
deleteLocalFile: bridge?.deleteLocalFile,
|
||||
stageUploadFile: bridge?.stageUploadFile,
|
||||
cancelStagedUploadFile: bridge?.cancelStagedUploadFile,
|
||||
@@ -857,10 +860,18 @@ export const useSftpExternalOperations = (
|
||||
if (!b?.statSftp) return null;
|
||||
return b.statSftp(sftpId, path);
|
||||
},
|
||||
deleteSftp: async (sftpId: string, path: string) => {
|
||||
// Only wire when present so uploadService can fall back via `lstat ?? stat`.
|
||||
lstatSftp: bridge?.lstatSftp
|
||||
? async (sftpId: string, path: string) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (!b?.lstatSftp) return null;
|
||||
return b.lstatSftp(sftpId, path);
|
||||
}
|
||||
: undefined,
|
||||
deleteSftp: async (sftpId: string, path: string, expectedType) => {
|
||||
const b = netcattyBridge.get();
|
||||
if (b?.deleteSftp) {
|
||||
await b.deleteSftp(sftpId, path);
|
||||
await b.deleteSftp(sftpId, path, undefined, expectedType);
|
||||
}
|
||||
},
|
||||
// Stream transfer for large files (avoids loading into memory).
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
describeSftpExistingKind,
|
||||
describeSftpIncomingKind,
|
||||
getSftpConflictTypeKey,
|
||||
shouldUnlinkSftpConflictBeforeReplace,
|
||||
} from "../../../domain/sftpConflict";
|
||||
import {
|
||||
findActivePathConflict,
|
||||
@@ -23,6 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -444,7 +446,7 @@ export const useSftpTransfers = ({
|
||||
cleanupTaskArtifacts,
|
||||
});
|
||||
|
||||
const { statTargetPath, getDuplicateTarget } = useSftpTransferConflictOps();
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
|
||||
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
ownerId,
|
||||
@@ -740,8 +742,10 @@ export const useSftpTransfers = ({
|
||||
newModified: sourceStat?.mtime || Date.now(),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error) {
|
||||
// Missing path = no conflict. ENOTSUP / unknown type fail closed.
|
||||
if (isMissingStatError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
@@ -783,6 +787,13 @@ export const useSftpTransfers = ({
|
||||
return "failed";
|
||||
}
|
||||
|
||||
if (
|
||||
defaultAction === "replace"
|
||||
&& shouldUnlinkSftpConflictBeforeReplace(conflict.existingType)
|
||||
) {
|
||||
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding, "symlink");
|
||||
}
|
||||
|
||||
const duplicateTarget = defaultAction === "duplicate"
|
||||
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
|
||||
: null;
|
||||
@@ -1537,6 +1548,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
const updatedTasks: TransferTask[] = [];
|
||||
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
|
||||
const failedReplaceTasks: Array<{ task: TransferTask; error: string }> = [];
|
||||
|
||||
for (const affectedTask of affectedTasks) {
|
||||
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
|
||||
@@ -1568,6 +1580,34 @@ export const useSftpTransfers = ({
|
||||
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
affectedConflict
|
||||
&& shouldUnlinkSftpConflictBeforeReplace(affectedConflict.existingType)
|
||||
) {
|
||||
const endpoints = resolveTaskEndpoints(affectedTask);
|
||||
if (!endpoints?.targetPane.connection) continue;
|
||||
const targetSftpId = endpoints.targetPane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(endpoints.targetPane.connection.id) ?? null;
|
||||
const targetEncoding = endpoints.targetPane.connection.isLocal
|
||||
? "auto"
|
||||
: endpoints.targetPane.filenameEncoding || "auto";
|
||||
try {
|
||||
await deleteTargetPath(
|
||||
affectedTask,
|
||||
endpoints.targetPane,
|
||||
targetSftpId,
|
||||
targetEncoding,
|
||||
"symlink",
|
||||
);
|
||||
} catch (error) {
|
||||
failedReplaceTasks.push({
|
||||
task: affectedTask,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
updatedTask = {
|
||||
...affectedTask,
|
||||
skipConflictCheck: true,
|
||||
@@ -1607,6 +1647,24 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (failedReplaceTasks.length > 0) {
|
||||
const failedErrors = new Map(
|
||||
failedReplaceTasks.map(({ task: failedTask, error }) => [failedTask.id, error]),
|
||||
);
|
||||
setTransfers((prev) =>
|
||||
prev.map((candidate) => failedErrors.has(candidate.id)
|
||||
? {
|
||||
...candidate,
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: failedErrors.get(candidate.id),
|
||||
conflict: undefined,
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const liveUpdatedTasks = updatedTasks.filter((candidate) => !cancelledTasksRef.current.has(candidate.id));
|
||||
const updatedTaskMap = new Map(liveUpdatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
|
||||
setTransfers((prev) =>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("startup landing setting participates in cross-window settings sync", () => {
|
||||
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
|
||||
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(storageSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(storageSyncSource, /setStartupLandingState/);
|
||||
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
|
||||
assert.match(ipcSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(ipcSyncSource, /setStartupLandingState/);
|
||||
assert.match(ipcSyncSource, /key === STORAGE_KEY_STARTUP_LANDING/);
|
||||
});
|
||||
|
||||
test("startup landing does not bump the cloud sync settings version", () => {
|
||||
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
|
||||
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
|
||||
|
||||
assert.notEqual(settingsVersionIndex, -1);
|
||||
assert.doesNotMatch(settingsVersionSource, /startupLanding/);
|
||||
});
|
||||
|
||||
test("main window opens local terminal on cold start when preferred", () => {
|
||||
const sideEffectsSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(sideEffectsSource, /startupLocalTerminalAttemptedRef/);
|
||||
assert.match(sideEffectsSource, /shouldOpenLocalTerminalOnStartup/);
|
||||
assert.match(sideEffectsSource, /STORAGE_KEY_STARTUP_LANDING/);
|
||||
assert.match(sideEffectsSource, /handleCreateLocalTerminal\(/);
|
||||
assert.match(sideEffectsSource, /onColdStartIntentsSettled/);
|
||||
assert.match(sideEffectsSource, /ensureDiscoveredShells/);
|
||||
assert.match(sideEffectsSource, /hasQueuedStartupIntent:\s*startupLaunchIntentReceivedRef\.current/);
|
||||
|
||||
const latchAt = sideEffectsSource.indexOf("if (startupLocalTerminalAttemptedRef.current) return;");
|
||||
const setLatchAt = sideEffectsSource.indexOf(
|
||||
"startupLocalTerminalAttemptedRef.current = true;",
|
||||
latchAt,
|
||||
);
|
||||
const createAt = sideEffectsSource.indexOf("handleCreateLocalTerminal(", setLatchAt);
|
||||
assert.notEqual(latchAt, -1);
|
||||
assert.notEqual(setLatchAt, -1);
|
||||
assert.notEqual(createAt, -1);
|
||||
assert.ok(setLatchAt < createAt, "StrictMode latch must be set before creating the terminal");
|
||||
});
|
||||
|
||||
test("settings system tab exposes startup landing control", () => {
|
||||
const tabSource = readFileSync(
|
||||
new URL("../../components/settings/tabs/SettingsSystemTab.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(tabSource, /system-startup-landing/);
|
||||
assert.match(tabSource, /settings\.sessionRestore\.startupLanding/);
|
||||
assert.match(tabSource, /local-terminal/);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canReuseTerminalConnection,
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
isTerminalSessionEligibleForSftpReuse,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
@@ -24,6 +25,14 @@ test("connected SSH sessions can reuse their authenticated connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
|
||||
});
|
||||
|
||||
test("SSH sessions stay SFTP-linkable while reconnecting", () => {
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "connecting" })), true);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "disconnected" })), true);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ protocol: "local" })), false);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ moshEnabled: true })), false);
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined) &&
|
||||
!session.moshEnabled &&
|
||||
!session.etEnabled &&
|
||||
session.status === "connected"
|
||||
(session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled
|
||||
);
|
||||
}
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
|
||||
}
|
||||
|
||||
type CloneSessionOptions = {
|
||||
id: string;
|
||||
localShellType?: TerminalSession["shellType"];
|
||||
|
||||
@@ -490,6 +490,341 @@ test("does not replace an existing directory when uploading a same-named file",
|
||||
assert.match(results[0].error ?? "", /directory/i);
|
||||
});
|
||||
|
||||
test("file replace leaves the remote destination so upload can restore mode bits", async () => {
|
||||
// Deleting before overwrite recreates the inode with umask defaults and drops
|
||||
// bits like +x (#2954). Keep the target so stage+rename can restore mode.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async (_sftpId, path) =>
|
||||
path === "/usr/local/bin/tool.sh"
|
||||
? { type: "file", size: 9, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, [], "file replace must not delete before upload");
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("file replace unlinks an existing symlink before upload", async () => {
|
||||
// Leaving the symlink would let in-place upload follow it and overwrite the
|
||||
// link target outside the displayed directory.
|
||||
// Conflict checks use lstatSftp so Replace unlinks the link first.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
// Followed stat would report type "file" and skip pre-delete.
|
||||
statSftp: async () => ({ type: "file", size: 4096, lastModified: 1000 }),
|
||||
lstatSftp: async (_sftpId, path) =>
|
||||
path === "/usr/local/bin/tool.sh"
|
||||
? { type: "symlink", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("LSTAT ENOTSUP is not treated as an absent destination during conflict check", async () => {
|
||||
// If unsupported LSTAT is swallowed as "missing", upload skips Replace and may
|
||||
// write through an existing symlink to a target outside the displayed dir.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
let uploaded = 0;
|
||||
let conflictPrompts = 0;
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error(
|
||||
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
||||
) as Error & { code: string; lstatUnavailable: boolean };
|
||||
error.code = "ENOTSUP";
|
||||
error.lstatUnavailable = true;
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async () => {
|
||||
uploaded += 1;
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
conflictPrompts += 1;
|
||||
return "replace";
|
||||
},
|
||||
},
|
||||
),
|
||||
(error: Error & { code?: string }) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
assert.match(String(error.message), /does not support LSTAT/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(uploaded, 0, "must not upload when destination type is unknown");
|
||||
assert.equal(conflictPrompts, 0, "must not pretend destination is missing");
|
||||
});
|
||||
|
||||
test("ENOENT from lstat still means absent destination for conflict check", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/usr/local/bin",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error("No such file") as Error & { code: string };
|
||||
error.code = "ENOENT";
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => {
|
||||
throw new Error("absent destination must not prompt for conflict");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
|
||||
let compressedStarts = 0;
|
||||
installCompressedUploadBridge(t, {
|
||||
onStart: () => { compressedStarts += 1; },
|
||||
});
|
||||
let streamCalls = 0;
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFromFileList(
|
||||
[createPickedFolderFile()],
|
||||
{
|
||||
targetPath: "/remote",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatSftp: async () => {
|
||||
const error = new Error(
|
||||
"Remote server does not support LSTAT; cannot classify path without following symlinks",
|
||||
) as Error & { code: string; lstatUnavailable: boolean };
|
||||
error.code = "ENOTSUP";
|
||||
error.lstatUnavailable = true;
|
||||
throw error;
|
||||
},
|
||||
startStreamTransfer: async (payload) => {
|
||||
streamCalls += 1;
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
useCompressedUpload: true,
|
||||
resolveConflict: async () => "merge",
|
||||
},
|
||||
),
|
||||
(error: Error & { code?: string }) => {
|
||||
assert.equal(error.code, "ENOTSUP");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(compressedStarts, 0);
|
||||
assert.equal(streamCalls, 0);
|
||||
});
|
||||
|
||||
test("directory replace unlinks an existing symlink before mkdir", async () => {
|
||||
// No-follow lstat reports symlink; Replace must unlink then create the dir
|
||||
// (followed stat used to classify symlink-to-dir as directory and succeed).
|
||||
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
|
||||
Object.defineProperty(file, "path", { value: "/local/docs/file.txt" });
|
||||
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const madeDirs: string[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/remote",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async (_sftpId, path) => {
|
||||
madeDirs.push(path);
|
||||
},
|
||||
lstatSftp: async (_sftpId, path) =>
|
||||
path === "/remote/docs"
|
||||
? { type: "symlink", size: 8, lastModified: 1000 }
|
||||
: null,
|
||||
deleteSftp: async (_sftpId, path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
startStreamTransfer: async ({ targetPath: path }) => {
|
||||
uploadedPaths.push(path);
|
||||
return { transferId: "upload-dir-1" };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/remote/docs"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.ok(madeDirs.includes("/remote/docs"), `expected mkdir after unlink, got ${JSON.stringify(madeDirs)}`);
|
||||
assert.deepEqual(uploadedPaths, ["/remote/docs/file.txt"]);
|
||||
assert.equal(results.some((r) => r.success), true);
|
||||
});
|
||||
|
||||
test("local file replace unlinks an existing symlink before writeLocalFile", async () => {
|
||||
// Pathless File uploads fall back to writeLocalFile, which follows symlinks.
|
||||
// Conflict checks use lstatLocal so Replace unlinks the link first.
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
const deletedPaths: string[] = [];
|
||||
const deletedTypes: Array<string | undefined> = [];
|
||||
const writtenPaths: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/Users/me/bin",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatLocal: async (path) =>
|
||||
path === "/Users/me/bin/tool.sh"
|
||||
? { type: "symlink", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteLocalFile: async (path, expectedType) => {
|
||||
deletedPaths.push(path);
|
||||
deletedTypes.push(expectedType);
|
||||
},
|
||||
writeLocalFile: async (path) => {
|
||||
writtenPaths.push(path);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(deletedPaths, ["/Users/me/bin/tool.sh"]);
|
||||
assert.deepEqual(deletedTypes, ["symlink"]);
|
||||
assert.deepEqual(writtenPaths, ["/Users/me/bin/tool.sh"]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("local file replace unlinks a regular file before writeLocalFile", async () => {
|
||||
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
|
||||
const operations: string[] = [];
|
||||
|
||||
const results = await uploadFromFileList(
|
||||
[file],
|
||||
{
|
||||
targetPath: "/Users/me/bin",
|
||||
sftpId: null,
|
||||
isLocal: true,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
lstatLocal: async (path) =>
|
||||
path === "/Users/me/bin/tool.sh"
|
||||
? { type: "file", size: 12, lastModified: 1000 }
|
||||
: null,
|
||||
deleteLocalFile: async (path, expectedType) => {
|
||||
operations.push(`delete:${path}:${expectedType}`);
|
||||
},
|
||||
writeLocalFile: async (path) => {
|
||||
operations.push(`write:${path}`);
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async () => "replace",
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(operations, [
|
||||
"delete:/Users/me/bin/tool.sh:file",
|
||||
"write:/Users/me/bin/tool.sh",
|
||||
]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].success, true);
|
||||
});
|
||||
|
||||
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
|
||||
const files = [
|
||||
new File(["local"], "existing-file", { lastModified: 1234 }),
|
||||
|
||||
@@ -109,6 +109,7 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
|
||||
"conflictActionOverride: 'download-remote'",
|
||||
recoveryPromptIndex,
|
||||
);
|
||||
const manualTriggerIndex = source.indexOf("trigger: 'manual'", recoveryPromptIndex);
|
||||
const normalSyncIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
cloudWinsIndex,
|
||||
@@ -118,16 +119,41 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
|
||||
assert.notEqual(previewIndex, -1);
|
||||
assert.notEqual(recoveryPromptIndex, -1);
|
||||
assert.notEqual(cloudWinsIndex, -1);
|
||||
assert.notEqual(manualTriggerIndex, -1);
|
||||
assert.notEqual(normalSyncIndex, -1);
|
||||
assert.ok(
|
||||
convergentGuardIndex < previewIndex
|
||||
&& previewIndex < recoveryPromptIndex
|
||||
&& recoveryPromptIndex < cloudWinsIndex
|
||||
&& recoveryPromptIndex < manualTriggerIndex
|
||||
&& manualTriggerIndex < cloudWinsIndex
|
||||
&& cloudWinsIndex < normalSyncIndex,
|
||||
"v2 startup must offer recovery and use cloud-wins before the normal CRDT sync path",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty-vault recovery restore bypasses the auto-sync preference gate", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const recoveryPromptIndex = source.indexOf("requestEmptyVaultRecovery(recoveryPayload)");
|
||||
const restoreCallIndex = source.indexOf("syncNowRef.current({", recoveryPromptIndex);
|
||||
const triggerIndex = source.indexOf("trigger: 'manual'", restoreCallIndex);
|
||||
const conflictIndex = source.indexOf(
|
||||
"conflictActionOverride: 'download-remote'",
|
||||
restoreCallIndex,
|
||||
);
|
||||
const callEndIndex = source.indexOf("});", conflictIndex);
|
||||
|
||||
assert.notEqual(recoveryPromptIndex, -1);
|
||||
assert.notEqual(restoreCallIndex, -1);
|
||||
assert.notEqual(triggerIndex, -1);
|
||||
assert.notEqual(conflictIndex, -1);
|
||||
assert.ok(
|
||||
restoreCallIndex < triggerIndex
|
||||
&& triggerIndex < conflictIndex
|
||||
&& conflictIndex < callEndIndex,
|
||||
"recovery Restore must pass trigger:manual so disabled auto-sync cannot block it",
|
||||
);
|
||||
});
|
||||
|
||||
test("empty v2 startup checks allow a validated empty sync to open the gate", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
@@ -192,6 +218,38 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("auto syncNow and debounce re-check manager autoSyncEnabled before pushing", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const helperIndex = source.indexOf("function isPersistedAutoSyncEnabled");
|
||||
const syncNowIndex = source.indexOf("const syncNow = useCallback");
|
||||
const autoGateIndex = source.indexOf(
|
||||
"if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
|
||||
syncNowIndex,
|
||||
);
|
||||
const debounceIndex = source.indexOf("// Debounced auto-sync when data changes");
|
||||
const fireTimeGateIndex = source.indexOf(
|
||||
"if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
|
||||
debounceIndex,
|
||||
);
|
||||
const clearTimerIndex = source.indexOf(
|
||||
"if (syncTimeoutRef.current) {\n clearTimeout(syncTimeoutRef.current);\n syncTimeoutRef.current = null;\n }\n return;",
|
||||
debounceIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(helperIndex, -1);
|
||||
assert.notEqual(syncNowIndex, -1);
|
||||
assert.notEqual(autoGateIndex, -1);
|
||||
assert.notEqual(fireTimeGateIndex, -1);
|
||||
assert.notEqual(clearTimerIndex, -1);
|
||||
assert.ok(
|
||||
helperIndex < syncNowIndex
|
||||
&& syncNowIndex < autoGateIndex
|
||||
&& debounceIndex < fireTimeGateIndex
|
||||
&& debounceIndex < clearTimerIndex,
|
||||
"auto-sync off must gate both syncNow(auto) and the debounced timer path",
|
||||
);
|
||||
});
|
||||
|
||||
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
healPoisonedSecretsForMerge,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
|
||||
import {
|
||||
@@ -51,6 +51,19 @@ import {
|
||||
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
|
||||
import { getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
|
||||
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
|
||||
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
|
||||
const preferences = localStorageAdapter.read<{ autoSync?: boolean }>(
|
||||
SYNC_STORAGE_KEYS.SYNC_PREFERENCES,
|
||||
);
|
||||
if (preferences && typeof preferences === 'object' && preferences.autoSync !== undefined) {
|
||||
return Boolean(preferences.autoSync);
|
||||
}
|
||||
const stored = localStorageAdapter.read<{ autoSync?: boolean }>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
|
||||
if (!stored || typeof stored !== 'object') return fallback;
|
||||
return Boolean(stored.autoSync);
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
enabled?: boolean;
|
||||
// Data to sync
|
||||
@@ -312,6 +325,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
const trigger: SyncTrigger = options?.trigger ?? 'auto';
|
||||
|
||||
// Defense for #2976: auto pushes must honor the persisted preference even
|
||||
// when this window's React/manager snapshot is briefly stale after another
|
||||
// window disables auto-sync.
|
||||
if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSyncRunningRef.current = true;
|
||||
try {
|
||||
// Get fresh state directly from CloudSyncManager singleton
|
||||
@@ -671,7 +691,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (recoveryPayload && shouldPromptCloudVaultRecovery(localPayload, recoveryPayload)) {
|
||||
const userAction = await requestEmptyVaultRecovery(recoveryPayload);
|
||||
if (userAction === 'restore') {
|
||||
// Explicit recovery must not honor the auto-sync preference gate:
|
||||
// the user already confirmed Restore in the dialog.
|
||||
const restored = await syncNowRef.current({
|
||||
trigger: 'manual',
|
||||
notifyOnFailure,
|
||||
conflictActionOverride: 'download-remote',
|
||||
});
|
||||
@@ -973,6 +996,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
// Skip if not ready
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
// Drop any pending debounce when auto-sync (or readiness) turns off so a
|
||||
// timer scheduled while enabled cannot fire after the toggle (#2976).
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1030,6 +1059,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check at fire time: auto-sync may have been disabled in another
|
||||
// window during the debounce window (#2976).
|
||||
if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for the current sync to finish, then this effect will re-run
|
||||
// because sync.isSyncing changed.
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
|
||||
|
||||
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
|
||||
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
|
||||
const handlerSource = readFileSync(
|
||||
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
|
||||
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
|
||||
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
|
||||
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
|
||||
assert.doesNotMatch(
|
||||
hookSource,
|
||||
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live session sync has a complete renderer to main-process IPC path', () => {
|
||||
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
|
||||
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
|
||||
});
|
||||
|
||||
test('latest payload sync converges to A-B-A while B is in flight', async () => {
|
||||
let releaseB: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'B') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseB = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sync.push('A');
|
||||
const sendingB = sync.push('B');
|
||||
void sync.push('A');
|
||||
releaseB?.();
|
||||
await sendingB;
|
||||
|
||||
assert.deepEqual(sent, ['A', 'B', 'A']);
|
||||
});
|
||||
|
||||
test('cancelling a payload sync drops a queued update', async () => {
|
||||
let releaseA: (() => void) | undefined;
|
||||
const sent: string[] = [];
|
||||
const sync = createLatestPayloadSync(async (payload: string) => {
|
||||
sent.push(payload);
|
||||
if (payload === 'A') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseA = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const sendingA = sync.push('A');
|
||||
void sync.push('B');
|
||||
sync.cancel();
|
||||
releaseA?.();
|
||||
await sendingA;
|
||||
|
||||
assert.deepEqual(sent, ['A']);
|
||||
});
|
||||
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
};
|
||||
|
||||
export function createLatestPayloadSync<T>(
|
||||
send: (payload: T) => Promise<unknown>,
|
||||
) {
|
||||
let desired: { serialized: string; payload: T } | null = null;
|
||||
let acknowledged = '';
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const pump = async () => {
|
||||
while (!cancelled && desired && desired.serialized !== acknowledged) {
|
||||
const target = desired;
|
||||
try {
|
||||
const result = await send(target.payload);
|
||||
if (cancelled) break;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
if (desired === target) break;
|
||||
continue;
|
||||
}
|
||||
acknowledged = target.serialized;
|
||||
} catch {
|
||||
if (desired === target) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
|
||||
if (cancelled) return Promise.resolve();
|
||||
desired = { payload, serialized };
|
||||
if (!inFlight) {
|
||||
inFlight = pump().finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
},
|
||||
cancel(): void {
|
||||
cancelled = true;
|
||||
desired = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMainAppWindow(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash || '';
|
||||
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
|
||||
void enabledTick;
|
||||
return readExternalMcpStoredEnabled();
|
||||
}, [enabledTick]);
|
||||
const syncGenerationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEnabledTick((value) => value + 1);
|
||||
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
|
||||
});
|
||||
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
|
||||
|
||||
const lastSentSerializedRef = useRef('');
|
||||
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateLiveSessions) return;
|
||||
const serialized = JSON.stringify(payload);
|
||||
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
|
||||
));
|
||||
void liveSyncRef.current.push(payload, serialized);
|
||||
}, [payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMainAppWindow()) return;
|
||||
if (!enabled) {
|
||||
syncGenerationRef.current += 1;
|
||||
lastSentSerializedRef.current = '';
|
||||
// Recreate the synchronizer after re-enabling because disabling can
|
||||
// clear the main-process scope even when the renderer payload is equal.
|
||||
externalSyncRef.current?.cancel();
|
||||
externalSyncRef.current = null;
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === lastSentSerializedRef.current) return;
|
||||
|
||||
const generation = ++syncGenerationRef.current;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
|
||||
.then((result) => {
|
||||
if (generation !== syncGenerationRef.current) return;
|
||||
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
|
||||
return;
|
||||
}
|
||||
lastSentSerializedRef.current = serialized;
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave the ref unset so the next effect can retry.
|
||||
});
|
||||
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
|
||||
Promise.resolve(bridge.aiMcpUpdateSessions?.(
|
||||
nextPayload,
|
||||
EXTERNAL_MCP_CHAT_SESSION_ID,
|
||||
))
|
||||
));
|
||||
void externalSyncRef.current.push(payload, serialized);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
useOutputTriggers,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'group-trigger',
|
||||
label: 'Group trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('equivalent host objects do not reset a pending output trigger match', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const snippets: Snippet[] = [{
|
||||
id: 'stable-host-trigger',
|
||||
label: 'Stable host trigger',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targetGroups: ['Production'],
|
||||
}];
|
||||
let appendOutput: ((chunk: string) => void) | undefined;
|
||||
let runCount = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onRunScript = () => {
|
||||
runCount += 1;
|
||||
};
|
||||
|
||||
const Probe = ({ host }: { host: { id: string; group: string } }) => {
|
||||
appendOutput = useOutputTriggers({
|
||||
sessionId: 'session-1',
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}).appendOutput;
|
||||
return null;
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
appendOutput?.('REA');
|
||||
renderer?.update(React.createElement(Probe, {
|
||||
host: { id: 'host-a', group: 'Production/Web' },
|
||||
}));
|
||||
appendOutput?.('DY');
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.equal(runCount, 1);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
host?: Pick<Host, 'id' | 'group'>;
|
||||
snippets: Snippet[];
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
|
||||
export function hasApplicableOutputTriggerSnippet(
|
||||
snippets: Snippet[],
|
||||
host?: Pick<Host, 'id' | 'group'> | string,
|
||||
): boolean {
|
||||
return snippets.some((snippet) => (
|
||||
isScriptSnippet(snippet)
|
||||
&& snippet.trigger === 'onOutput'
|
||||
&& Boolean(snippet.triggerPattern)
|
||||
&& Boolean(snippet.id)
|
||||
&& snippetAppliesToOutputTrigger(snippet, hostId)
|
||||
&& snippetAppliesToOutputTrigger(snippet, host)
|
||||
));
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const targetHostId = host?.id;
|
||||
const targetHostGroup = host?.group;
|
||||
const targetHost = useMemo(
|
||||
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
|
||||
[targetHostGroup, targetHostId],
|
||||
);
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
@@ -600,8 +609,8 @@ export function useOutputTriggers({
|
||||
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
|
||||
const pendingDroppedOverflowScanStateResetRef = useRef(false);
|
||||
const hasOutputTriggers = useMemo(
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
|
||||
[hostId, snippets],
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
|
||||
[snippets, targetHost],
|
||||
);
|
||||
|
||||
const scanOutput = useCallback((scannableText: string) => {
|
||||
@@ -621,7 +630,7 @@ export function useOutputTriggers({
|
||||
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
|
||||
continue;
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
|
||||
if (!matched) {
|
||||
@@ -647,7 +656,7 @@ export function useOutputTriggers({
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
|
||||
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
|
||||
|
||||
const scanOutputRef = useRef(scanOutput);
|
||||
scanOutputRef.current = scanOutput;
|
||||
@@ -813,7 +822,7 @@ export function useOutputTriggers({
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => () => {
|
||||
outputTriggerEventProcessor.reset();
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_STARTUP_LANDING,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
@@ -146,6 +147,10 @@ import {
|
||||
type HostClickBehavior,
|
||||
} from './settingsStateDefaults';
|
||||
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
import {
|
||||
resolveStartupLandingSetting,
|
||||
type StartupLanding,
|
||||
} from '../../domain/startupLanding';
|
||||
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
|
||||
import { sessionRestoreStorage } from './sessionRestoreStorage';
|
||||
import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
@@ -431,6 +436,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
return resolveRestoreTerminalCwdSetting(stored);
|
||||
});
|
||||
const [startupLanding, setStartupLandingState] = useState<StartupLanding>(() => {
|
||||
return resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING));
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
return resolveSftpTransferConcurrency(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
@@ -955,6 +963,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
|
||||
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
|
||||
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
|
||||
setStartupLandingState(resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING)));
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -1123,6 +1132,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
setStartupLandingState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
@@ -1153,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1163,7 +1173,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1360,6 +1370,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setStartupLanding = useCallback((landing: StartupLanding) => {
|
||||
setStartupLandingState(landing);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_STARTUP_LANDING, landing);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
applyCustomCssToDocument(customCSS);
|
||||
@@ -1964,6 +1981,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
startupLanding,
|
||||
setStartupLanding,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
sshTransportIdleTtlMs,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
|
||||
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
|
||||
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
|
||||
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
|
||||
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
|
||||
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
|
||||
import {
|
||||
createTransferPoolKeyCache,
|
||||
@@ -316,9 +316,7 @@ export const useSftpState = (
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
const sourceSessionId = !host.sftpSudo
|
||||
? resolveTransferSourceSessionId?.(host.id, host)
|
||||
: undefined;
|
||||
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
|
||||
if (sourceSessionId) {
|
||||
try {
|
||||
logger.info(
|
||||
@@ -608,16 +606,7 @@ export const useSftpState = (
|
||||
forceReconnect: ensureOptions?.forceReconnect,
|
||||
releaseConnection,
|
||||
tabId,
|
||||
probeSession: async (sftpId) => {
|
||||
// Lightweight liveness check; any session-error from the bridge
|
||||
// triggers a reconnect in ensureRemoteSftpSession.
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
@@ -704,6 +693,12 @@ export const useSftpState = (
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || pane.connection.isLocal) continue;
|
||||
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
|
||||
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
|
||||
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
|
||||
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
|
||||
const resolvedSourceSessionId = targetHost
|
||||
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
|
||||
: undefined;
|
||||
try {
|
||||
await ensureRemoteSftpSession({
|
||||
side,
|
||||
@@ -713,16 +708,10 @@ export const useSftpState = (
|
||||
connect,
|
||||
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
|
||||
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
|
||||
resolveSourceSessionId: resolveBrowseSourceSessionId,
|
||||
probeSession: async (sftpId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSftpHomeDir) return true;
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result && result.success === false) {
|
||||
throw new Error(result.error || "SFTP session not found");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
resolveSourceSessionId: resolvedSourceSessionId
|
||||
? () => resolvedSourceSessionId
|
||||
: resolveBrowseSourceSessionId,
|
||||
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
|
||||
releaseConnection,
|
||||
});
|
||||
logger.info(`[SFTP] Restored browse session on ${side}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveVaultAgentEffectiveHost,
|
||||
resolveVaultAgentNotes,
|
||||
} from './useVaultAgentBridge';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { publishNotesSnapshot } from './notesStore';
|
||||
|
||||
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
|
||||
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host = {
|
||||
const host: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'production', tags: [], os: 'linux',
|
||||
} as const;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
resolveDefaultKeyPassphraseAliases,
|
||||
} from '../defaultKeyPassphrases';
|
||||
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
|
||||
export interface UseVaultAgentBridgeInput {
|
||||
hosts: Host[];
|
||||
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
|
||||
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
||||
updateSnippets: (snippets: Snippet[]) => void;
|
||||
updateSnippets: (
|
||||
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
) => void;
|
||||
customGroups: string[];
|
||||
updateCustomGroups: (groups: string[]) => void;
|
||||
groupConfigs: GroupConfig[];
|
||||
updateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
updateManagedSources: (sources: ManagedSource[]) => void;
|
||||
commitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
|
||||
notes?: VaultNote[];
|
||||
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.hosts = hosts;
|
||||
current.updateHosts(hosts);
|
||||
},
|
||||
commitVaultGroupMutation: async (mutate) => {
|
||||
const result = await current.commitVaultGroupMutation(mutate);
|
||||
if (result.ok) {
|
||||
vaultSnapshotRef.current.customGroups = result.state.groups;
|
||||
vaultSnapshotRef.current.groupConfigs = result.state.configs;
|
||||
vaultSnapshotRef.current.hosts = result.state.hosts;
|
||||
vaultSnapshotRef.current.managedSources = result.state.managedSources;
|
||||
vaultSnapshotRef.current.snippets = result.state.snippets;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
|
||||
keyPath,
|
||||
passphrase,
|
||||
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
applyUpdateNotes(notes);
|
||||
},
|
||||
updateSnippets: (nextSnippets) => {
|
||||
vaultSnapshotRef.current.snippets = nextSnippets;
|
||||
current.updateSnippets(nextSnippets);
|
||||
updateSnippets: (snippetUpdate) => {
|
||||
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
|
||||
? snippetUpdate(vaultSnapshotRef.current.snippets)
|
||||
: snippetUpdate;
|
||||
current.updateSnippets(snippetUpdate);
|
||||
},
|
||||
startTunnel: current.startTunnel,
|
||||
stopTunnel: current.stopTunnel,
|
||||
|
||||
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
|
||||
managedSources: storedSources,
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => storedSources,
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
) => {
|
||||
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(storedGroups),
|
||||
sources: updateSources(storedSources),
|
||||
groupConfigs: committedGroupConfigs,
|
||||
};
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: storedGroups,
|
||||
configs: storedGroupConfigs,
|
||||
hosts: [],
|
||||
managedSources: storedSources,
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) committedGroupConfigs = result.state.configs;
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultImportTransaction: async () => {
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
commitCount += 1;
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
};
|
||||
throw new Error("unexpected commit");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
restoreCount += 1;
|
||||
};
|
||||
},
|
||||
onCommitVaultImportTransaction: async (
|
||||
_hosts,
|
||||
updateGroups,
|
||||
updateSources,
|
||||
updateGroupConfigs,
|
||||
expectedHosts,
|
||||
) => {
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
commitCount += 1;
|
||||
assert.deepEqual(expectedHosts, []);
|
||||
return {
|
||||
status: "persisted",
|
||||
groups: updateGroups(["Production"]),
|
||||
sources: updateSources([firstSource, concurrentSource]),
|
||||
groupConfigs: updateGroupConfigs?.([]) ?? [],
|
||||
};
|
||||
return mutate({
|
||||
groups: ["Production"],
|
||||
configs: [],
|
||||
hosts: [],
|
||||
managedSources: [firstSource, concurrentSource],
|
||||
snippets: [],
|
||||
});
|
||||
},
|
||||
});
|
||||
return null;
|
||||
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(commitCount, 1);
|
||||
});
|
||||
|
||||
test("group deletion restores managed files and does not report success when commit fails", async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const source: ManagedSource = {
|
||||
id: "managed-production",
|
||||
type: "ssh_config",
|
||||
filePath: "/tmp/managed-production.conf",
|
||||
groupName: "Production",
|
||||
lastSyncedAt: 1,
|
||||
};
|
||||
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let restoreCount = 0;
|
||||
let reportedCount = 0;
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["Production"],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [source],
|
||||
onReadPersistedHosts: async () => [],
|
||||
onReadPersistedManagedSources: () => [source],
|
||||
onClearAndRemoveManagedSources: async () => async () => {
|
||||
restoreCount += 1;
|
||||
},
|
||||
onCommitVaultGroupMutation: async () => {
|
||||
throw new Error("Vault quota exhausted");
|
||||
},
|
||||
onDeletedPaths: () => {
|
||||
reportedCount += 1;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
let deletionError: unknown;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
try {
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["Production"]);
|
||||
});
|
||||
} catch (error) {
|
||||
deletionError = error;
|
||||
}
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.match(String(deletionError), /quota exhausted/i);
|
||||
assert.equal(restoreCount, 1);
|
||||
assert.equal(reportedCount, 0);
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
type VaultLockHandle,
|
||||
withVaultImportLock,
|
||||
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths,
|
||||
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
|
||||
managedSources: ManagedSource[];
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onReadPersistedManagedSources: () => ManagedSource[];
|
||||
onCommitVaultImportTransaction: (
|
||||
hosts: Host[],
|
||||
updateGroups: (current: string[]) => string[],
|
||||
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
groups: string[];
|
||||
sources: ManagedSource[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onDeletedPaths?: (selectedRoots: string[]) => void;
|
||||
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
|
||||
}
|
||||
deletion = refreshedDeletion;
|
||||
|
||||
const expectedHosts = latestRef.current.hosts;
|
||||
const expectedSources = deletion.sourcesToRemove;
|
||||
const nextHosts = deletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
const transaction = await onCommitVaultImportTransaction(
|
||||
nextHosts,
|
||||
(currentGroups) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: currentGroups,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: [],
|
||||
}).customGroups,
|
||||
(currentSources) => {
|
||||
const transaction = await onCommitVaultGroupMutation(
|
||||
(current) => {
|
||||
const currentDeletion = buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
managedSources: currentSources,
|
||||
customGroups: current.groups,
|
||||
hosts: current.hosts,
|
||||
groupConfigs: current.configs,
|
||||
managedSources: current.managedSources,
|
||||
});
|
||||
if (!managedSourceSnapshotsMatch(
|
||||
expectedSources,
|
||||
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const removedIds = new Set(expectedSources.map((source) => source.id));
|
||||
return currentSources.filter((source) => !removedIds.has(source.id));
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
),
|
||||
),
|
||||
snippets: removeSnippetTargetGroupPaths(
|
||||
current.snippets,
|
||||
currentDeletion.selectedRoots,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
(currentGroupConfigs) => buildVaultGroupDeletion({
|
||||
selectedPaths,
|
||||
deleteHosts,
|
||||
customGroups: [],
|
||||
hosts: [],
|
||||
groupConfigs: currentGroupConfigs,
|
||||
managedSources: [],
|
||||
}).groupConfigs,
|
||||
expectedHosts,
|
||||
lock,
|
||||
);
|
||||
if (transaction.status === "superseded") {
|
||||
// Release the lock so concurrent host saves can finish before retry.
|
||||
if (!transaction.ok && "superseded" in transaction) {
|
||||
// Release the lock so concurrent saves can finish before retry.
|
||||
return { status: "retry" as const, restoreManagedFiles };
|
||||
}
|
||||
if ("error" in transaction) {
|
||||
throw new Error(transaction.error);
|
||||
}
|
||||
latestRef.current = {
|
||||
customGroups: transaction.groups,
|
||||
hosts: nextHosts,
|
||||
groupConfigs: transaction.groupConfigs,
|
||||
managedSources: transaction.sources,
|
||||
customGroups: transaction.state.groups,
|
||||
hosts: transaction.state.hosts,
|
||||
groupConfigs: transaction.state.configs,
|
||||
managedSources: transaction.state.managedSources,
|
||||
};
|
||||
return {
|
||||
status: "done" as const,
|
||||
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
|
||||
}, [
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onDeletedPaths,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
|
||||
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
|
||||
// and be visible to waitForPendingVaultWrites.
|
||||
assert.match(
|
||||
source,
|
||||
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
|
||||
// Group deletion can wait for managed-source cleanup. A script created while
|
||||
// it waits must still be present when the deletion callback removes the old
|
||||
// group path, so updater callbacks must not derive from the initiating render.
|
||||
assert.match(
|
||||
source,
|
||||
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
// Clear All Local Data must not preserve concurrent disk-only snippet adds
|
||||
// that rebaseSnippetVaultWrite would otherwise keep.
|
||||
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
|
||||
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
|
||||
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -106,10 +106,15 @@ import {
|
||||
persistVaultImportMetadata,
|
||||
readStoredArray,
|
||||
} from "./vaultImportPersistence";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../../domain/vaultGroupMutation";
|
||||
import {
|
||||
commitPluginImporterTransaction,
|
||||
recoverPluginImporterTransaction,
|
||||
} from "./pluginImporterTransaction";
|
||||
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -296,6 +301,7 @@ export const useVaultState = () => {
|
||||
const managedSourcesRef = useRef<ManagedSource[]>([]);
|
||||
const hostsRef = useRef<Host[]>([]);
|
||||
const snippetsRef = useRef<Snippet[]>([]);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>([]);
|
||||
const notesRef = useRef<VaultNote[]>([]);
|
||||
const noteGroupsRef = useRef<string[]>([]);
|
||||
const notesPersistFailureNotifiedAtRef = useRef(0);
|
||||
@@ -303,6 +309,7 @@ export const useVaultState = () => {
|
||||
managedSourcesRef.current = managedSources;
|
||||
hostsRef.current = hosts;
|
||||
snippetsRef.current = snippets;
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
notesRef.current = notes;
|
||||
noteGroupsRef.current = noteGroups;
|
||||
|
||||
@@ -314,6 +321,7 @@ export const useVaultState = () => {
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const snippetsWriteVersion = useRef(0);
|
||||
const managedSourcesWriteVersion = useRef(0);
|
||||
// Tracks the latest local updateSnippets schedule. Storage events also bump
|
||||
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
|
||||
// key supersede checks off this owner instead of that shared counter.
|
||||
@@ -340,6 +348,7 @@ export const useVaultState = () => {
|
||||
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const waitForPendingVaultWrites = useCallback(async () => {
|
||||
while (true) {
|
||||
@@ -365,6 +374,7 @@ export const useVaultState = () => {
|
||||
identitiesWritePendingRef.current,
|
||||
groupConfigsWritePendingRef.current,
|
||||
snippetsWritePendingRef.current,
|
||||
managedSourcesWritePendingRef.current,
|
||||
];
|
||||
await Promise.all(writePending);
|
||||
if (
|
||||
@@ -373,6 +383,7 @@ export const useVaultState = () => {
|
||||
&& writePending[2] === identitiesWritePendingRef.current
|
||||
&& writePending[3] === groupConfigsWritePendingRef.current
|
||||
&& writePending[4] === snippetsWritePendingRef.current
|
||||
&& writePending[5] === managedSourcesWritePendingRef.current
|
||||
&& encryptPending[0] === hostsEncryptPendingRef.current
|
||||
&& encryptPending[1] === keysEncryptPendingRef.current
|
||||
&& encryptPending[2] === identitiesEncryptPendingRef.current
|
||||
@@ -643,7 +654,7 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((
|
||||
data: Snippet[],
|
||||
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
|
||||
options?: { replace?: boolean },
|
||||
) => {
|
||||
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
|
||||
@@ -653,7 +664,9 @@ export const useVaultState = () => {
|
||||
// second edit does not rebase against the first save's optimistic memory.
|
||||
const replace =
|
||||
options?.replace === true || snippetsWriteReplaceRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
|
||||
const updater = typeof data === "function" ? data : null;
|
||||
const current = snippetsRef.current;
|
||||
const base = snippetsWriteBaseRef.current ?? current;
|
||||
if (options?.replace === true) {
|
||||
snippetsWriteReplaceRef.current = true;
|
||||
}
|
||||
@@ -667,7 +680,9 @@ export const useVaultState = () => {
|
||||
} else if (snippetsWriteBaseRef.current === null) {
|
||||
snippetsWriteBaseRef.current = base;
|
||||
}
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
const cleaned = normalizeVaultOrder(
|
||||
typeof data === "function" ? data(current) : data,
|
||||
);
|
||||
const ver = ++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = ver;
|
||||
// Keep live snapshot ahead of React commit so same-tick readers (delete,
|
||||
@@ -681,20 +696,25 @@ export const useVaultState = () => {
|
||||
// skips the additive rebase so a concurrent disk-only add cannot survive.
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
|
||||
const rebased = normalizeVaultOrder(
|
||||
replace
|
||||
? cleaned
|
||||
const latestPersisted = normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
);
|
||||
const rebased = normalizeVaultOrder(replace
|
||||
? cleaned
|
||||
: updater
|
||||
? updater(rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: current,
|
||||
theirs: latestPersisted,
|
||||
}))
|
||||
: rebaseSnippetVaultWrite({
|
||||
base,
|
||||
ours: cleaned,
|
||||
theirs: normalizeVaultOrder(
|
||||
readStoredArray<Snippet>(
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
theirs: latestPersisted,
|
||||
}));
|
||||
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
|
||||
if (!persisted) {
|
||||
// Keep the pre-update ancestor. Clearing it here would make the next
|
||||
@@ -852,6 +872,7 @@ export const useVaultState = () => {
|
||||
setCustomGroups(next);
|
||||
|
||||
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
|
||||
groupConfigsRef.current = cleanedGroupConfigs;
|
||||
setGroupConfigs(cleanedGroupConfigs);
|
||||
const configsVer = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
|
||||
@@ -880,13 +901,105 @@ export const useVaultState = () => {
|
||||
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
void withVaultImportLock("vault", async () => {
|
||||
const ver = ++managedSourcesWriteVersion.current;
|
||||
const writePromise = withVaultImportLock("vault", async () => {
|
||||
// Latest ref wins if another update ran while waiting for the lock.
|
||||
if (managedSourcesRef.current !== next) return;
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
|
||||
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
|
||||
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
|
||||
? "written" as const
|
||||
: "failed" as const;
|
||||
});
|
||||
managedSourcesWritePendingRef.current = writePromise;
|
||||
return writePromise;
|
||||
}, []);
|
||||
|
||||
const commitVaultGroupMutation = useCallback(async (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const captureVersions = () => ({
|
||||
hosts: hostsWriteVersion.current,
|
||||
snippets: snippetsWriteVersion.current,
|
||||
groups: customGroupsWriteVersion.current,
|
||||
configs: groupConfigsWriteVersion.current,
|
||||
sources: managedSourcesWriteVersion.current,
|
||||
});
|
||||
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
|
||||
versions.hosts === hostsWriteVersion.current
|
||||
&& versions.snippets === snippetsWriteVersion.current
|
||||
&& versions.groups === customGroupsWriteVersion.current
|
||||
&& versions.configs === groupConfigsWriteVersion.current
|
||||
&& versions.sources === managedSourcesWriteVersion.current
|
||||
);
|
||||
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
|
||||
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
|
||||
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
|
||||
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
|
||||
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
|
||||
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
|
||||
);
|
||||
const runCommit = async (
|
||||
versions: ReturnType<typeof captureVersions>,
|
||||
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage: localStorageAdapter,
|
||||
mutate,
|
||||
prepareState: (state) => {
|
||||
const groups = Array.from(new Set(state.groups));
|
||||
return {
|
||||
groups,
|
||||
configs: buildGroupConfigsForGroups(groups, state.configs),
|
||||
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
|
||||
managedSources: state.managedSources,
|
||||
snippets: normalizeVaultOrder(state.snippets),
|
||||
};
|
||||
},
|
||||
decryptHosts: async (items) => normalizeVaultOrder(
|
||||
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
|
||||
),
|
||||
decryptConfigs: async (items) => normalizeVaultOrder(
|
||||
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
|
||||
),
|
||||
encryptHosts,
|
||||
encryptConfigs: encryptGroupConfigs,
|
||||
isCurrent: () => versionsAreCurrent(versions),
|
||||
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
const nextState = result.state;
|
||||
++hostsWriteVersion.current;
|
||||
++snippetsWriteVersion.current;
|
||||
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
|
||||
++customGroupsWriteVersion.current;
|
||||
++groupConfigsWriteVersion.current;
|
||||
++managedSourcesWriteVersion.current;
|
||||
hostsRef.current = nextState.hosts;
|
||||
snippetsRef.current = nextState.snippets;
|
||||
customGroupsRef.current = nextState.groups;
|
||||
managedSourcesRef.current = nextState.managedSources;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextState.hosts);
|
||||
setSnippets(nextState.snippets);
|
||||
setCustomGroups(nextState.groups);
|
||||
setManagedSources(nextState.managedSources);
|
||||
setGroupConfigs(nextState.configs);
|
||||
groupConfigsRef.current = nextState.configs;
|
||||
return { ok: true, state: nextState };
|
||||
};
|
||||
|
||||
if (lock) {
|
||||
const versions = captureVersions();
|
||||
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
|
||||
}
|
||||
while (true) {
|
||||
await waitForPendingVaultWrites();
|
||||
const versions = captureVersions();
|
||||
const result = await withVaultImportLock("vault", () => runCommit(versions));
|
||||
if (!("superseded" in result)) return result;
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
|
||||
readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
|
||||
++groupConfigsWriteVersion.current;
|
||||
customGroupsRef.current = result.groups;
|
||||
managedSourcesRef.current = result.sources;
|
||||
hostsRef.current = cleanedHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
setHosts(cleanedHosts);
|
||||
setCustomGroups(result.groups);
|
||||
setManagedSources(result.sources);
|
||||
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = cleaned;
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
const encryptPromise = encryptGroupConfigs(cleaned);
|
||||
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
if (savedManagedSources) {
|
||||
managedSourcesRef.current = savedManagedSources;
|
||||
setManagedSources(savedManagedSources);
|
||||
}
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
|
||||
if (cancelled) return;
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = sanitizedGC;
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_GROUPS) {
|
||||
const next = safeParse<string[]>(event.newValue) ?? [];
|
||||
++customGroupsWriteVersion.current;
|
||||
customGroupsRef.current = next;
|
||||
setCustomGroups(next);
|
||||
return;
|
||||
}
|
||||
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
++managedSourcesWriteVersion.current;
|
||||
managedSourcesRef.current = next;
|
||||
setManagedSources(next);
|
||||
return;
|
||||
}
|
||||
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
|
||||
const seq = ++groupConfigsReadSeq.current;
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
|
||||
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
|
||||
groupConfigsRef.current = normalized;
|
||||
setGroupConfigs(normalized);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
|
||||
]);
|
||||
customGroupsRef.current = nextGroups;
|
||||
snippetsRef.current = nextSnippets;
|
||||
hostsRef.current = nextHosts;
|
||||
groupConfigsRef.current = nextGroupConfigs;
|
||||
snippetsWriteBaseRef.current = null;
|
||||
snippetsWriteReplaceRef.current = false;
|
||||
setHosts(nextHosts);
|
||||
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
|
||||
updateManagedSources,
|
||||
readPersistedManagedSources,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Host, Snippet } from '../../domain/models';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
removeSnippetTargetGroupPaths,
|
||||
} from '../../domain/hostGroupPathMutations';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
|
||||
const createStorage = (initial: Record<string, unknown>, failKey?: string) => {
|
||||
const values = new Map(Object.entries(initial).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
return {
|
||||
values,
|
||||
read<T>(key: string): T | null {
|
||||
const value = values.get(key);
|
||||
return value === undefined ? null : JSON.parse(value) as T;
|
||||
},
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write<T>(key: string, value: T) {
|
||||
if (key === failKey) return false;
|
||||
values.set(key, JSON.stringify(value));
|
||||
return true;
|
||||
},
|
||||
writeString(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
remove(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const identity = async <T>(value: T): Promise<T> => value;
|
||||
const prepareState = <T>(value: T): T => value;
|
||||
|
||||
const initialHost: Host = {
|
||||
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
|
||||
group: 'prod', tags: [], os: 'linux',
|
||||
};
|
||||
const initialSnippet: Snippet = {
|
||||
id: 'script-1', label: 'Deploy', command: 'nct.log(1)', kind: 'script',
|
||||
targetGroups: ['prod'],
|
||||
};
|
||||
|
||||
function initialVault() {
|
||||
return {
|
||||
[STORAGE_KEY_HOSTS]: [initialHost],
|
||||
[STORAGE_KEY_GROUPS]: ['prod'],
|
||||
[STORAGE_KEY_MANAGED_SOURCES]: [],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', username: 'root' }],
|
||||
[STORAGE_KEY_SNIPPETS]: [initialSnippet],
|
||||
};
|
||||
}
|
||||
|
||||
test('group rename rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
configs: current.configs.map((config) => ({ ...config, path: 'production' })),
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: 'production' })),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group delete rolls back every Vault key when the snippet write fails', async () => {
|
||||
const initial = initialVault();
|
||||
const storage = createStorage(initial, STORAGE_KEY_SNIPPETS);
|
||||
|
||||
await assert.rejects(commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts: current.hosts.map((host) => ({ ...host, group: undefined })),
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
}), /rejected importer transaction/);
|
||||
|
||||
for (const [key, value] of Object.entries(initial)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('group rename preserves a concurrent snippet update from the locked snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: ['production'],
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, 'prod', 'production'),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
['production'],
|
||||
['production/child'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group delete cleans targets from the latest persisted snippet snapshot', async () => {
|
||||
const concurrentSnippet = { ...initialSnippet, command: 'nct.log(2)' };
|
||||
const addedSnippet: Snippet = {
|
||||
id: 'script-2', label: 'Check', command: 'nct.log(3)', kind: 'script',
|
||||
targetGroups: ['prod/child', 'staging'],
|
||||
};
|
||||
const storage = createStorage({
|
||||
...initialVault(),
|
||||
[STORAGE_KEY_SNIPPETS]: [concurrentSnippet, addedSnippet],
|
||||
});
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => ({
|
||||
ok: true,
|
||||
state: {
|
||||
...current,
|
||||
groups: [],
|
||||
snippets: removeSnippetTargetGroupPaths(current.snippets, ['prod']),
|
||||
},
|
||||
}),
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const saved = storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS) ?? [];
|
||||
assert.equal(saved[0]?.command, 'nct.log(2)');
|
||||
assert.deepEqual(saved.map((snippet) => snippet.targetGroups), [
|
||||
[],
|
||||
['staging'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('group mutation stops before writing when memory is ahead of persisted Vault state', async () => {
|
||||
const storage = createStorage(initialVault());
|
||||
let mutateCalled = false;
|
||||
|
||||
const result = await commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate: (current) => {
|
||||
mutateCalled = true;
|
||||
return { ok: true, state: current };
|
||||
},
|
||||
prepareState,
|
||||
decryptHosts: identity,
|
||||
decryptConfigs: identity,
|
||||
encryptHosts: identity,
|
||||
encryptConfigs: identity,
|
||||
isCurrent: () => true,
|
||||
validateCurrent: (current) => current.snippets[0]?.command === 'nct.log(2)',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, superseded: true });
|
||||
assert.equal(mutateCalled, false);
|
||||
assert.deepEqual(storage.read<Snippet[]>(STORAGE_KEY_SNIPPETS), [initialSnippet]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GroupConfig, Host, ManagedSource, Snippet } from '../../domain/models';
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from '../../domain/vaultGroupMutation';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { commitPluginImporterTransaction } from './pluginImporterTransaction';
|
||||
import { readStoredArray } from './vaultImportPersistence';
|
||||
|
||||
type TransactionStorage = {
|
||||
read<T>(key: string): T | null;
|
||||
readString(key: string): string | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
writeString(key: string, value: string): boolean;
|
||||
remove(key: string): void;
|
||||
};
|
||||
|
||||
const GROUP_MUTATION_KEYS = [
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
] as const;
|
||||
|
||||
export async function commitVaultGroupMutationPersistence({
|
||||
storage,
|
||||
mutate,
|
||||
prepareState,
|
||||
decryptHosts,
|
||||
decryptConfigs,
|
||||
encryptHosts,
|
||||
encryptConfigs,
|
||||
isCurrent,
|
||||
validateCurrent,
|
||||
}: {
|
||||
storage: TransactionStorage;
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult;
|
||||
prepareState: (state: VaultGroupMutationState) => VaultGroupMutationState;
|
||||
decryptHosts: (hosts: Host[]) => Promise<Host[]>;
|
||||
decryptConfigs: (configs: GroupConfig[]) => Promise<GroupConfig[]>;
|
||||
encryptHosts: (hosts: Host[]) => Promise<unknown>;
|
||||
encryptConfigs: (configs: GroupConfig[]) => Promise<unknown>;
|
||||
isCurrent: () => boolean;
|
||||
validateCurrent?: (current: VaultGroupMutationState) => boolean;
|
||||
}): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> {
|
||||
const raw = new Map(GROUP_MUTATION_KEYS.map((key) => [key, storage.readString(key)]));
|
||||
const [hosts, configs] = await Promise.all([
|
||||
decryptHosts(readStoredArray<Host>(STORAGE_KEY_HOSTS, raw.get(STORAGE_KEY_HOSTS) ?? null)),
|
||||
decryptConfigs(readStoredArray<GroupConfig>(
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
raw.get(STORAGE_KEY_GROUP_CONFIGS) ?? null,
|
||||
)),
|
||||
]);
|
||||
const current: VaultGroupMutationState = {
|
||||
groups: readStoredArray<string>(STORAGE_KEY_GROUPS, raw.get(STORAGE_KEY_GROUPS) ?? null),
|
||||
configs,
|
||||
hosts,
|
||||
managedSources: readStoredArray<ManagedSource>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
raw.get(STORAGE_KEY_MANAGED_SOURCES) ?? null,
|
||||
),
|
||||
snippets: readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, raw.get(STORAGE_KEY_SNIPPETS) ?? null),
|
||||
};
|
||||
if (!isCurrent() || (validateCurrent && !validateCurrent(current))) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
const mutation = mutate(current);
|
||||
if (!mutation.ok) return mutation;
|
||||
|
||||
const nextState = prepareState(mutation.state);
|
||||
const [encryptedHosts, encryptedConfigs] = await Promise.all([
|
||||
encryptHosts(nextState.hosts),
|
||||
encryptConfigs(nextState.configs),
|
||||
]);
|
||||
if (
|
||||
!isCurrent()
|
||||
|| GROUP_MUTATION_KEYS.some((key) => storage.readString(key) !== raw.get(key))
|
||||
) {
|
||||
return { ok: false, superseded: true };
|
||||
}
|
||||
|
||||
commitPluginImporterTransaction(storage, [
|
||||
[STORAGE_KEY_HOSTS, encryptedHosts],
|
||||
[STORAGE_KEY_GROUPS, nextState.groups],
|
||||
[STORAGE_KEY_MANAGED_SOURCES, nextState.managedSources],
|
||||
[STORAGE_KEY_GROUP_CONFIGS, encryptedConfigs],
|
||||
[STORAGE_KEY_SNIPPETS, nextState.snippets],
|
||||
]);
|
||||
return { ok: true, state: nextState };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface VaultHostTreeActions {
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
commitInlineGroupRename: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
|
||||
@@ -81,6 +81,7 @@ test('vault actions register and unregister', () => {
|
||||
readPersistedManagedSources: noop,
|
||||
commitPluginImporterData: noop,
|
||||
commitVaultImportTransaction: noop,
|
||||
commitVaultGroupMutation: noop,
|
||||
updateHostDistro: noop,
|
||||
updateHostLastConnected: noop,
|
||||
addShellHistoryEntry: noop,
|
||||
|
||||
@@ -58,6 +58,7 @@ export type VaultSnapshotActions = Pick<
|
||||
| 'readPersistedManagedSources'
|
||||
| 'commitPluginImporterData'
|
||||
| 'commitVaultImportTransaction'
|
||||
| 'commitVaultGroupMutation'
|
||||
| 'updateHostDistro'
|
||||
| 'updateHostLastConnected'
|
||||
| 'addShellHistoryEntry'
|
||||
|
||||
Binary file not shown.
@@ -290,7 +290,7 @@ interface HostTreeViewProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
@@ -330,7 +330,7 @@ interface TreeNodeProps {
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { CheckSquare, FolderTree, Search, Square } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import type { Host } from '@/domain/models';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FixedSizeVirtualList } from '@/components/ui/FixedSizeVirtualList';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectGroupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
selectedGroupPaths: string[];
|
||||
onSelectionChange: (selectedGroupPaths: string[]) => void;
|
||||
}
|
||||
|
||||
const GROUP_ROW_HEIGHT = 48;
|
||||
|
||||
function collectGroupPaths(
|
||||
hosts: Host[],
|
||||
customGroups: string[],
|
||||
selectedGroupPaths: string[],
|
||||
): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const groupPath of [...customGroups, ...selectedGroupPaths]) {
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
paths.add(parts.slice(0, index).join('/'));
|
||||
}
|
||||
}
|
||||
return [...paths].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function countHostsByGroupPath(hosts: Host[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const host of hosts) {
|
||||
if (host.protocol === 'serial') continue;
|
||||
const parts = host.group?.split('/').filter(Boolean) ?? [];
|
||||
for (let index = 1; index <= parts.length; index += 1) {
|
||||
const path = parts.slice(0, index).join('/');
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export const SelectGroupDialog: React.FC<SelectGroupDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
hosts,
|
||||
customGroups = [],
|
||||
selectedGroupPaths,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]);
|
||||
const groupPaths = useMemo(
|
||||
() => collectGroupPaths(hosts, customGroups, selectedGroupPaths),
|
||||
[customGroups, hosts, selectedGroupPaths],
|
||||
);
|
||||
const displayedPaths = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return groupPaths;
|
||||
return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query));
|
||||
}, [groupPaths, searchQuery]);
|
||||
const hostCountByPath = useMemo(() => countHostsByGroupPath(hosts), [hosts]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
if (selected.has(path)) {
|
||||
onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path));
|
||||
return;
|
||||
}
|
||||
onSelectionChange([...selectedGroupPaths, path]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md h-[min(78vh,600px)] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 shrink-0 border-b border-border/60">
|
||||
<DialogTitle className="text-base">{t('snippets.targets.selectGroups')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border/60 shrink-0">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
{displayedPaths.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t('snippets.targets.noGroups')}
|
||||
</p>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
items={displayedPaths}
|
||||
itemHeight={GROUP_ROW_HEIGHT}
|
||||
getItemKey={(path) => path}
|
||||
renderItem={(path) => {
|
||||
const isSelected = selected.has(path);
|
||||
const depth = Math.max(0, path.split('/').filter(Boolean).length - 1);
|
||||
const label = path.split('/').filter(Boolean).pop() ?? path;
|
||||
const hostCount = hostCountByPath.get(path) ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full h-11 flex items-center gap-2.5 rounded-lg px-2.5 text-left transition-colors',
|
||||
isSelected ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
||||
)}
|
||||
style={{ paddingLeft: `${10 + depth * 16}px` }}
|
||||
onClick={() => togglePath(path)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<Square size={16} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<FolderTree size={16} className="text-primary shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{path}</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||
{t('vault.groups.hostsCount', { count: hostCount })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t('selectHost.continueWithCount', { count: selectedGroupPaths.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -627,6 +627,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
setRestoreTerminalCwd={settings.setRestoreTerminalCwd}
|
||||
startupLanding={settings.startupLanding}
|
||||
setStartupLanding={settings.setStartupLanding}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
|
||||
@@ -86,8 +86,12 @@ import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
rememberSftpSidePanelSourceStatus,
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
} from "./sftp/sftpSidePanelAutoConnect";
|
||||
import {
|
||||
@@ -116,7 +120,7 @@ interface SftpSidePanelProps {
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
/** The terminal session id whose SSH connection can be reused for SFTP */
|
||||
/** Linked same-endpoint SSH session id (may be reconnecting; reuse only when connected) */
|
||||
activeSessionId?: string | null;
|
||||
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
|
||||
focusedSessionId?: string | null;
|
||||
@@ -219,7 +223,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
}, [hosts, sessions]);
|
||||
|
||||
// Browse restore can run before the session list reflects the focused tab;
|
||||
// prefer the active source when its visible endpoint matches the pane host.
|
||||
// prefer the active source when its visible endpoint matches the pane host
|
||||
// and the SSH transport is already up (linked id survives reconnect phases).
|
||||
const resolveBrowseSourceSessionId = useCallback((hostId: string, host?: Host) => {
|
||||
if (
|
||||
activeSessionId
|
||||
@@ -227,10 +232,13 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
&& activeHost.id === hostId
|
||||
&& (!host || sftpHostEndpointsEqual(activeHost, host))
|
||||
) {
|
||||
return activeSessionId;
|
||||
const linkedSession = sessions.find((session) => session.id === activeSessionId);
|
||||
if (linkedSession?.status === "connected") {
|
||||
return activeSessionId;
|
||||
}
|
||||
}
|
||||
return resolveTransferSourceSessionId(hostId, host);
|
||||
}, [activeHost, activeSessionId, resolveTransferSourceSessionId]);
|
||||
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -493,6 +501,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const connectedKeyRef = useRef<string | null>(null);
|
||||
const connectedHostObjRef = useRef<Host | null>(null);
|
||||
const lastSourceSessionIdRef = useRef<string | null>(null);
|
||||
const lastSourceSessionStatusRef = useRef<string | null>(null);
|
||||
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
||||
const handledPendingUploadIdRef = useRef<string | null>(null);
|
||||
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
||||
@@ -560,10 +569,36 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
activeHost.username,
|
||||
activeHost.sftpFileProtocol,
|
||||
);
|
||||
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
||||
lastSourceSessionIdRef.current,
|
||||
activeSessionId,
|
||||
);
|
||||
const pendingSameEndpointSession = sessions.find((session) => (
|
||||
isPendingSameEndpointSshSession(session, activeHost)
|
||||
));
|
||||
if (!activeSessionId && pendingSameEndpointSession) {
|
||||
return;
|
||||
}
|
||||
const activeSession = activeSessionId
|
||||
? sessions.find((session) => session.id === activeSessionId) ?? null
|
||||
: null;
|
||||
const activeSessionStatus = activeSession?.status ?? null;
|
||||
if (
|
||||
shouldDeferSftpSidePanelAutoConnectForSession({
|
||||
activeSessionId,
|
||||
sessionStatus: activeSessionStatus,
|
||||
})
|
||||
) {
|
||||
// Remember the non-connected status so the later connected transition
|
||||
// is treated as a transport rebind (same session id after Start over).
|
||||
lastSourceSessionStatusRef.current = activeSessionStatus;
|
||||
if (activeSessionId) {
|
||||
lastSourceSessionIdRef.current = activeSessionId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: lastSourceSessionIdRef.current,
|
||||
nextSessionId: activeSessionId,
|
||||
previousStatus: lastSourceSessionStatusRef.current,
|
||||
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
||||
});
|
||||
|
||||
const hasBackendSession = (connectionId: string) => !!s.getSftpIdForConnection(connectionId);
|
||||
const activeTab = s.leftTabs.tabs.find((tab) => tab.id === s.leftTabs.activeTabId) ?? null;
|
||||
@@ -592,6 +627,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (activeSessionId) {
|
||||
lastSourceSessionIdRef.current = activeSessionId;
|
||||
}
|
||||
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
|
||||
previousStatus: lastSourceSessionStatusRef.current,
|
||||
activeSessionId,
|
||||
activeSessionStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Defer advancing the session cursor while interactive work blocks rebind,
|
||||
@@ -600,6 +640,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
if (activeSessionId) {
|
||||
lastSourceSessionIdRef.current = activeSessionId;
|
||||
}
|
||||
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
|
||||
previousStatus: lastSourceSessionStatusRef.current,
|
||||
activeSessionId,
|
||||
activeSessionStatus,
|
||||
});
|
||||
|
||||
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
||||
hostId: activeHost.id,
|
||||
@@ -730,14 +775,16 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
connectedKeyRef.current = connectionKey;
|
||||
connectedHostObjRef.current = activeHost;
|
||||
s.connect("left", activeHost, {
|
||||
sourceSessionId: activeSessionId ?? undefined,
|
||||
sourceSessionId: activeSessionStatus === "connected"
|
||||
? (activeSessionId ?? undefined)
|
||||
: undefined,
|
||||
...(initialPath ? { initialPath } : undefined),
|
||||
...(needsNewTab ? { forceNewTab: true } : undefined),
|
||||
onTabCreated: (tabId) => {
|
||||
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
||||
},
|
||||
});
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive]);
|
||||
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHost || !isVisible) return;
|
||||
@@ -752,6 +799,20 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
};
|
||||
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) return;
|
||||
const trackedSessionId = lastSourceSessionIdRef.current;
|
||||
const trackedSession = trackedSessionId
|
||||
? sessions.find((candidate) => candidate.id === trackedSessionId) ?? null
|
||||
: null;
|
||||
const update = resolveSftpSidePanelTrackedSourceStatusUpdate({
|
||||
trackedSessionId,
|
||||
sessionStatus: trackedSession?.status ?? null,
|
||||
});
|
||||
if (!update) return;
|
||||
lastSourceSessionStatusRef.current = update.status;
|
||||
}, [activeSessionId, sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const connection = sftp.leftPane.connection;
|
||||
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
type SnippetExportPayload,
|
||||
type SnippetImportConflictAction,
|
||||
} from '../domain/snippetTransfer';
|
||||
import { getRunnableHostsForSnippet, snippetHasRunTargets } from '../domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
snippetHasRunTargets,
|
||||
} from '../domain/snippetTargets.ts';
|
||||
import { removeHostConnectScript, syncHostsForSnippetTargetChange } from '../domain/hostConnectScripts.ts';
|
||||
import { flattenSnippetCommandPreview } from '../domain/snippetPreview.ts';
|
||||
import { deleteSelectedSnippetsFromVault } from '../domain/snippetSelection.ts';
|
||||
@@ -486,6 +490,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
@@ -735,6 +740,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || []));
|
||||
} else {
|
||||
setEditingSnippet(asScript ? {
|
||||
label: '',
|
||||
@@ -751,6 +757,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targets: [],
|
||||
});
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
}
|
||||
setRightPanelMode('edit-snippet');
|
||||
}, [selectedPackage]);
|
||||
@@ -773,6 +780,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
@@ -784,7 +795,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
trigger: editingSnippet.trigger,
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
};
|
||||
}, [editingSnippet, targetSelection]);
|
||||
}, [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const syncHostsAfterSnippetSave = useCallback((
|
||||
savedSnippet: Snippet,
|
||||
@@ -855,6 +866,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
setRightPanelMode('none');
|
||||
setEditingSnippet({ label: '', command: '', package: '', targets: [] });
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
};
|
||||
|
||||
const hostById = useMemo(() => (
|
||||
@@ -1663,6 +1675,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
customGroups={customGroups}
|
||||
targetSelection={targetSelection}
|
||||
setTargetSelection={setTargetSelection}
|
||||
targetGroupSelection={targetGroupSelection}
|
||||
setTargetGroupSelection={setTargetGroupSelection}
|
||||
handleTargetSelect={handleTargetSelect}
|
||||
handleTargetSelectionChange={handleTargetSelectionChange}
|
||||
handleTargetPickerBack={handleTargetPickerBack}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseSnippetVariables } from '../domain/snippetVariables';
|
||||
import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { SelectGroupDialog } from './SelectGroupDialog';
|
||||
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
@@ -17,7 +18,10 @@ import { Combobox } from './ui/combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { HistoryItem } from './SnippetsHistoryItem';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import { STORAGE_KEY_SNIPPETS_PANEL_WIDTH } from '@/infrastructure/config/storageKeys.ts';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -29,6 +33,8 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups,
|
||||
targetSelection,
|
||||
setTargetSelection,
|
||||
targetGroupSelection,
|
||||
setTargetGroupSelection,
|
||||
handleTargetSelect,
|
||||
handleTargetSelectionChange,
|
||||
handleTargetPickerBack,
|
||||
@@ -71,6 +77,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
[editingSnippet?.command],
|
||||
);
|
||||
const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet);
|
||||
const snippetsPanelResizeProps = {
|
||||
resizable: true as const,
|
||||
@@ -81,8 +88,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const runnableEditingSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableEditingSnippet, hosts),
|
||||
@@ -99,10 +110,12 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const handleTargetsAllHostsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -137,6 +150,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
|
||||
if (rightPanelMode === 'edit-snippet') {
|
||||
return (
|
||||
<>
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={handleClosePanel}
|
||||
@@ -266,7 +280,9 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={isEditingScript ? targetGroupSelection : []}
|
||||
onEditTargets={openTargetPicker}
|
||||
onEditGroups={isEditingScript ? () => setGroupPickerOpen(true) : undefined}
|
||||
hint={isEditingScript
|
||||
? (editingSnippet.trigger === 'onConnect'
|
||||
? t('scripts.targets.connectOrderHint')
|
||||
@@ -386,11 +402,29 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
) : null}
|
||||
</AsidePanel>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
setTargetGroupSelection(nextGroups);
|
||||
setEditingSnippet({
|
||||
...editingSnippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+30
-10
@@ -44,6 +44,7 @@ import {
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { shouldCollectServerStats } from "../domain/systemManager/systemTarget";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
|
||||
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
|
||||
@@ -121,6 +122,7 @@ import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
|
||||
import { isScriptSnippet } from "@/domain/snippetScript.ts";
|
||||
import { snippetCanRunInTerminal } from "@/domain/snippetTargets.ts";
|
||||
import { useOutputTriggers } from "@/application/state/useOutputTriggers.ts";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
@@ -427,7 +429,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scriptSessionName = sessionDisplayName || host.label;
|
||||
const outputTriggers = useOutputTriggers({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
host,
|
||||
snippets,
|
||||
onRunScript: (snippet, sid) => runAutomationScript({
|
||||
snippet,
|
||||
@@ -438,7 +440,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
},
|
||||
}).catch((err) => {
|
||||
}).then(() => undefined).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
|
||||
throw err;
|
||||
@@ -823,7 +825,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [isSessionLogging, setIsSessionLogging] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [progressValue, setProgressValue] = useState(CONNECTION_PROGRESS_START);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
const [connectionReuseAttemptSourceId, setConnectionReuseAttemptSourceId] = useState(
|
||||
@@ -1512,6 +1514,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
}
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
snapshot = serializeAddonRef.current.serialize() || "";
|
||||
} else if (hibernatedRef.current || softHiddenRef.current) {
|
||||
// Hibernate path: live xterm is torn down; use retained snapshot.
|
||||
@@ -1617,7 +1620,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalCwdTracker,
|
||||
]);
|
||||
|
||||
const cleanupSession = async () => {
|
||||
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
|
||||
const closingSessionId = sessionRef.current;
|
||||
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
|
||||
sessionRef.current = null;
|
||||
@@ -1645,7 +1648,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Still notify main so in-flight SSH passphrase prompts for this UI
|
||||
// sessionId are aborted even before a backend session was attached.
|
||||
try {
|
||||
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(sessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to cancel pending session boot on disconnect", err);
|
||||
}
|
||||
@@ -1692,6 +1698,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
let serializedSnapshot: unknown;
|
||||
try {
|
||||
await xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization();
|
||||
serializedSnapshot = serializeAddonRef.current?.serialize?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal snapshot for attach popup", err);
|
||||
@@ -1754,7 +1761,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
clearTerminalSessionFlowAck(closingSessionId);
|
||||
}
|
||||
try {
|
||||
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
|
||||
await terminalBackend.closeSession(closingSessionId, {
|
||||
bootEpoch: resolveCloseBootEpoch(),
|
||||
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to close SSH session", err);
|
||||
}
|
||||
@@ -2046,7 +2056,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snapshot = await serializeTerminalForHibernate(
|
||||
term,
|
||||
serializeAddon,
|
||||
{ preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current) },
|
||||
{
|
||||
preferWasm: resolveHibernatePreferWasmSerialize(terminalSettingsRef.current),
|
||||
prepare: () => xtermRuntimeRef.current?.keywordHighlighter.prepareForSerialization()
|
||||
?? Promise.resolve(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!canFinishHibernate()) return false;
|
||||
@@ -2168,6 +2182,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef,
|
||||
fitAddonRef,
|
||||
serializeAddonRef,
|
||||
prepareKeywordHighlightSerialization: () => xtermRuntimeRef.current
|
||||
?.keywordHighlighter.prepareForSerialization() ?? Promise.resolve(),
|
||||
searchAddonRef,
|
||||
hasRuntimeRef,
|
||||
}), []);
|
||||
@@ -3067,6 +3083,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
if (isScriptSnippet(snippet)) {
|
||||
if (!snippetCanRunInTerminal(snippet, host)) {
|
||||
toast.error(t('scripts.targets.currentHostMismatch'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
@@ -3089,7 +3109,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
executeSnippetCommand(command, snippet.noAutoRun, {
|
||||
multiLineRunMode: snippet.multiLineRunMode,
|
||||
});
|
||||
}, [executeSnippetCommand, host.hostname, host.username, scriptSessionName, sessionId, t]);
|
||||
}, [executeSnippetCommand, host, scriptSessionName, sessionId, t]);
|
||||
|
||||
const onSnippetShortkeyRef = useRef(executeSnippet);
|
||||
onSnippetShortkeyRef.current = executeSnippet;
|
||||
@@ -3303,7 +3323,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
setError(null);
|
||||
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
|
||||
void cleanupSession();
|
||||
void cleanupSession({ retainOwnership: true });
|
||||
updateStatus("disconnected");
|
||||
setChainProgress(null);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
@@ -3472,7 +3492,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupSession();
|
||||
await cleanupSession({ retainOwnership: true });
|
||||
} catch (error) {
|
||||
finishReconnectPreparation();
|
||||
throw error;
|
||||
|
||||
@@ -60,48 +60,66 @@ const renderVault = (sortMode: string | null, hosts: Host[]) => {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
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,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
{
|
||||
locale: "en",
|
||||
children: React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(VaultView, {
|
||||
hosts,
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
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,
|
||||
onReadPersistedHosts: async () => hosts,
|
||||
onReadPersistedManagedSources: () => [],
|
||||
onCommitPluginImporterData: async () => 0,
|
||||
onCommitVaultImportTransaction: async () => ({
|
||||
status: "persisted" as const,
|
||||
groups: [],
|
||||
sources: [],
|
||||
groupConfigs: [],
|
||||
}),
|
||||
onCommitVaultGroupMutation: async (mutate) => mutate({
|
||||
groups: [],
|
||||
configs: [],
|
||||
hosts,
|
||||
managedSources: [],
|
||||
snippets: [],
|
||||
}),
|
||||
onConvertKnownHost: noop,
|
||||
onOpenLogView: noop,
|
||||
groupConfigs: [],
|
||||
onUpdateGroupConfigs: noop,
|
||||
showRecentHosts: false,
|
||||
showOnlyUngroupedHostsInRoot: false,
|
||||
}),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
+91
-113
@@ -52,6 +52,10 @@ import type { VaultLockHandle } from "../application/state/vaultManagedImportLoc
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
|
||||
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
} from "../domain/vaultGroupMutation";
|
||||
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
|
||||
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
@@ -69,6 +73,9 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
} from "../domain/hostGroupPathMutations";
|
||||
import {
|
||||
reorderVaultItems,
|
||||
reorderVaultStrings,
|
||||
@@ -261,6 +268,10 @@ interface VaultViewProps {
|
||||
}
|
||||
| { status: "superseded" }
|
||||
>;
|
||||
onCommitVaultGroupMutation: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
lock?: VaultLockHandle | null,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
||||
onUnmanageSource?: (sourceId: string) => void;
|
||||
@@ -325,6 +336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateManagedSources,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onUnmanageSource,
|
||||
@@ -951,7 +963,66 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsNewFolderOpen(false);
|
||||
};
|
||||
|
||||
const submitRenameGroup = () => {
|
||||
const commitGroupPathChange = useCallback(async (
|
||||
sourcePath: string,
|
||||
nextPath: string,
|
||||
replacementConfig?: GroupConfig,
|
||||
) => {
|
||||
try {
|
||||
return await onCommitVaultGroupMutation((current) => {
|
||||
if (!current.groups.includes(sourcePath)) {
|
||||
return { ok: false, error: `Group "${sourcePath}" was not found.` };
|
||||
}
|
||||
const belongsToTree = (path: string) => (
|
||||
path === sourcePath || path.startsWith(`${sourcePath}/`)
|
||||
);
|
||||
const rename = (path: string) => (
|
||||
path === sourcePath
|
||||
? nextPath
|
||||
: path.startsWith(`${sourcePath}/`)
|
||||
? nextPath + path.slice(sourcePath.length)
|
||||
: path
|
||||
);
|
||||
const occupied = new Set([
|
||||
...current.groups.filter((path) => !belongsToTree(path)),
|
||||
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
|
||||
]);
|
||||
const collision = [
|
||||
...current.groups,
|
||||
...current.configs.map((config) => config.path),
|
||||
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
|
||||
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
|
||||
|
||||
const configs = current.configs.map((config) => {
|
||||
if (replacementConfig && config.path === sourcePath) {
|
||||
return { ...replacementConfig, path: nextPath };
|
||||
}
|
||||
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
|
||||
});
|
||||
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
|
||||
configs.push({ ...replacementConfig, path: nextPath });
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: Array.from(new Set(current.groups.map(rename))),
|
||||
configs,
|
||||
hosts: current.hosts.map((host) => (
|
||||
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
|
||||
)),
|
||||
managedSources: current.managedSources.map((source) => (
|
||||
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
|
||||
)),
|
||||
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}, [onCommitVaultGroupMutation]);
|
||||
|
||||
const submitRenameGroup = async () => {
|
||||
if (!renameTargetPath) return;
|
||||
|
||||
const nextName = renameGroupName.trim();
|
||||
@@ -972,37 +1043,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === renameTargetPath) return nextPath;
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return nextPath + g.slice(renameTargetPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === renameTargetPath) return { ...h, group: nextPath };
|
||||
if (g.startsWith(renameTargetPath + "/"))
|
||||
return { ...h, group: nextPath + g.slice(renameTargetPath.length) };
|
||||
return h;
|
||||
});
|
||||
|
||||
// Update managed sources if any match the renamed group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === renameTargetPath)
|
||||
return { ...s, groupName: nextPath };
|
||||
if (s.groupName.startsWith(renameTargetPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: nextPath + s.groupName.slice(renameTargetPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(renameTargetPath, nextPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === renameTargetPath ||
|
||||
@@ -1026,7 +1071,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSaveGroupConfig = useCallback(
|
||||
(config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
||||
const oldPath = editingGroupPath!;
|
||||
const newPath = config.path; // Panel already computed the correct path
|
||||
|
||||
@@ -1044,41 +1089,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
// Handle path change (rename or parent change)
|
||||
if (newPath !== oldPath) {
|
||||
// Update groups, hosts, managed sources, and configs for path change
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === oldPath) return newPath;
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return newPath + g.slice(oldPath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === oldPath) return { ...h, group: newPath };
|
||||
if (g.startsWith(oldPath + "/"))
|
||||
return { ...h, group: newPath + g.slice(oldPath.length) };
|
||||
return h;
|
||||
});
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === oldPath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(oldPath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(oldPath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
const result = await commitGroupPathChange(oldPath, newPath, config);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update child config paths too
|
||||
const finalConfigs = updatedConfigs.map((c) => {
|
||||
if (c.path.startsWith(oldPath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(oldPath.length) };
|
||||
return c;
|
||||
});
|
||||
onUpdateGroupConfigs(finalConfigs);
|
||||
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
|
||||
if (selectedGroupPath?.startsWith(oldPath + "/")) {
|
||||
setSelectedGroupPath(
|
||||
@@ -1096,13 +1111,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupConfigs,
|
||||
editingGroupPath,
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
selectedGroupPath,
|
||||
commitGroupPathChange,
|
||||
onUpdateGroupConfigs,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1129,7 +1140,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedSources,
|
||||
onReadPersistedHosts,
|
||||
onReadPersistedManagedSources,
|
||||
onCommitVaultImportTransaction,
|
||||
onCommitVaultGroupMutation,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onDeletedPaths: handleDeletedGroupPaths,
|
||||
@@ -1138,7 +1149,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
|
||||
deleteGroupPaths([path], deleteHosts);
|
||||
|
||||
const moveGroup = (sourcePath: string, targetParent: string | null) => {
|
||||
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
|
||||
const name = sourcePath.split("/").filter(Boolean).pop() || "";
|
||||
const newPath = targetParent ? `${targetParent}/${name}` : name;
|
||||
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
|
||||
@@ -1146,50 +1157,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
toast.error(t("vault.groups.errors.duplicatePath"));
|
||||
return;
|
||||
}
|
||||
const updatedGroups = customGroups.map((g) => {
|
||||
if (g === sourcePath) return newPath;
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return newPath + g.slice(sourcePath.length);
|
||||
return g;
|
||||
});
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === sourcePath) return { ...h, group: newPath };
|
||||
if (g.startsWith(sourcePath + "/"))
|
||||
return { ...h, group: newPath + g.slice(sourcePath.length) };
|
||||
return h;
|
||||
});
|
||||
// Update managed sources if any match the moved group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(sourcePath + "/"))
|
||||
return {
|
||||
...s,
|
||||
groupName: newPath + s.groupName.slice(sourcePath.length),
|
||||
};
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
// Update group configs for moved paths
|
||||
const updatedGroupConfigs = groupConfigs.map((c) => {
|
||||
if (c.path === sourcePath) return { ...c, path: newPath };
|
||||
if (c.path.startsWith(sourcePath + "/"))
|
||||
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
|
||||
return c;
|
||||
});
|
||||
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
|
||||
onUpdateGroupConfigs(updatedGroupConfigs);
|
||||
const result = await commitGroupPathChange(sourcePath, newPath);
|
||||
if ("superseded" in result || "error" in result) {
|
||||
toast.error("superseded" in result ? t("common.error") : result.error);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedGroupPath &&
|
||||
(selectedGroupPath === sourcePath ||
|
||||
selectedGroupPath.startsWith(sourcePath + "/"))
|
||||
) {
|
||||
setSelectedGroupPath(newPath);
|
||||
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1321,8 +1299,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
onCommitGroupPathChange: commitGroupPathChange,
|
||||
selectedGroupPath,
|
||||
setSelectedGroupPath,
|
||||
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
|
||||
@@ -1688,6 +1665,7 @@ export const vaultViewAreEqual = (
|
||||
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
|
||||
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
|
||||
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
|
||||
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
|
||||
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
|
||||
prev.showRecentHosts === next.showRecentHosts &&
|
||||
prev.hostClickBehavior === next.hostClickBehavior &&
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
|
||||
import type { Host, Snippet } from '@/domain/models';
|
||||
import {
|
||||
appendHostConnectScript,
|
||||
getEditableHostConnectScriptIds,
|
||||
getGlobalConnectScripts,
|
||||
getGroupConnectScriptsForHost,
|
||||
removeHostConnectScript,
|
||||
reorderHostConnectScript,
|
||||
} from '@/domain/hostConnectScripts.ts';
|
||||
@@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => getEditableHostConnectScriptIds(host, snippets),
|
||||
[host, snippets],
|
||||
);
|
||||
const groupScripts = useMemo(
|
||||
() => getGroupConnectScriptsForHost(host, snippets).filter(
|
||||
(script) => !script.id || !queueIds.includes(script.id),
|
||||
),
|
||||
[host, queueIds, snippets],
|
||||
);
|
||||
const groupScriptIds = useMemo(
|
||||
() => new Set(groupScripts.map((script) => script.id)),
|
||||
[groupScripts],
|
||||
);
|
||||
const queuedScripts = useMemo(
|
||||
() => queueIds
|
||||
.map((id) => scriptById(snippets, id))
|
||||
@@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
() => scripts.filter((script) => {
|
||||
if (!script.id) return false;
|
||||
if (script.targetsAllHosts) return false;
|
||||
if (groupScriptIds.has(script.id)) return false;
|
||||
if (queueIds.includes(script.id)) return false;
|
||||
return true;
|
||||
}),
|
||||
[queueIds, scripts],
|
||||
[groupScriptIds, queueIds, scripts],
|
||||
);
|
||||
const linkOptions = useMemo(
|
||||
() => linkableScripts.map((script) => ({
|
||||
@@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groupScripts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FolderTree size={12} className="text-muted-foreground shrink-0" />
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.automation.groupScripts')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t('hostDetails.automation.groupScriptsHint')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScripts.map((script) => (
|
||||
<div
|
||||
key={script.id}
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-md border border-border/50 bg-primary/5"
|
||||
>
|
||||
<VaultEntityIcon
|
||||
className={cn(vaultEntityIconSmClass, vaultAutomationScriptIconClass)}
|
||||
icon={<Play size={14} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{script.label || t('scripts.running.unnamed')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{script.targetGroups?.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{queuedScripts.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">{t('hostDetails.automation.connectQueueEmpty')}</p>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { HostTreeGroupInlineRenameInput } from './HostTreeGroupInlineRenameInput';
|
||||
|
||||
test('inline group rename can retry after an asynchronous commit failure', async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(HostTreeGroupInlineRenameInput, {
|
||||
initialName: 'prod',
|
||||
onCommit: async () => {
|
||||
attempts += 1;
|
||||
return attempts > 1;
|
||||
},
|
||||
onCancel: () => undefined,
|
||||
}));
|
||||
});
|
||||
const input = renderer!.root.findByType('input');
|
||||
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
input.props.onKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => undefined,
|
||||
stopPropagation: () => undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils';
|
||||
|
||||
type HostTreeGroupInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCommit: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -19,7 +19,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
@@ -28,15 +28,20 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
const commit = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
const committed = await onCommit(value);
|
||||
if (committed === false) submittingRef.current = false;
|
||||
} catch {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
@@ -49,7 +54,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
void commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -64,7 +69,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
void commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/domain/snippetScript.ts';
|
||||
import {
|
||||
getRunnableHostsForSnippet,
|
||||
resolveSnippetTargetGroupsForSave,
|
||||
} from '@/domain/snippetTargets.ts';
|
||||
import {
|
||||
removeHostConnectScript,
|
||||
@@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Partial<Snippet>>(createBlankScript);
|
||||
const [targetSelection, setTargetSelection] = useState<string[]>([]);
|
||||
const [targetGroupSelection, setTargetGroupSelection] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setEditingSnippet(createBlankScript());
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:scripts:add', handler);
|
||||
@@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (!snippet || !isScriptSnippet(snippet)) return;
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? []));
|
||||
setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? []));
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -112,6 +116,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
if (detail.editAfterSave) {
|
||||
setEditingSnippet(snippet);
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -132,8 +137,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const runnableSnippet = useMemo(() => ({
|
||||
...(editingSnippet as Snippet),
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
}), [editingSnippet, targetSelection]);
|
||||
}), [editingSnippet, targetGroupSelection, targetSelection]);
|
||||
|
||||
const runTargets = useMemo(
|
||||
() => getRunnableHostsForSnippet(runnableSnippet, hosts),
|
||||
@@ -174,6 +183,10 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
tags: editingSnippet.tags ?? [],
|
||||
package: packagePath,
|
||||
targets: editingSnippet.targetsAllHosts ? [] : targetSelection,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(
|
||||
editingSnippet,
|
||||
targetGroupSelection,
|
||||
),
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
kind: 'script',
|
||||
language: editingSnippet.language ?? 'javascript',
|
||||
@@ -182,7 +195,7 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
triggerPattern: editingSnippet.triggerPattern,
|
||||
order: editingSnippet.order,
|
||||
};
|
||||
}, [editingSnippet, onCreatePackage, packages, targetSelection]);
|
||||
}, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]);
|
||||
|
||||
const persistSnippet = useCallback((): Snippet | null => {
|
||||
const savedSnippet = buildSavedSnippet();
|
||||
@@ -238,10 +251,12 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
const handleTargetsAllHostsChange = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
setTargetSelection([]);
|
||||
setTargetGroupSelection([]);
|
||||
setEditingSnippet((prev) => ({
|
||||
...prev,
|
||||
targetsAllHosts: true,
|
||||
targets: [],
|
||||
targetGroups: undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -266,6 +281,8 @@ export const QuickScriptEditorDialog: React.FC<QuickScriptEditorDialogProps> = (
|
||||
selectedHostIds={targetSelection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
selectedGroupPaths={targetGroupSelection}
|
||||
onGroupSelectionChange={setTargetGroupSelection}
|
||||
targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)}
|
||||
onTargetsAllHostsChange={handleTargetsAllHostsChange}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { Host, Snippet } from '@/domain/models';
|
||||
import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts';
|
||||
import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus';
|
||||
import { SelectHostDialog } from '@/components/SelectHostDialog';
|
||||
import { SelectGroupDialog } from '@/components/SelectGroupDialog';
|
||||
import { ScriptCodeEditor } from './ScriptCodeEditor';
|
||||
import { ScriptMetaFields } from './ScriptMetaFields';
|
||||
import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection';
|
||||
import { resolveSnippetTargetGroupsForSave } from '@/domain/snippetTargets.ts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -26,6 +28,8 @@ export interface ScriptEditorModalProps {
|
||||
selectedHostIds: string[];
|
||||
onSelectHost: (host: Host) => void;
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
selectedGroupPaths?: string[];
|
||||
onGroupSelectionChange?: (selectedGroupPaths: string[]) => void;
|
||||
targetsAllHosts?: boolean;
|
||||
onTargetsAllHostsChange?: (checked: boolean) => void;
|
||||
}
|
||||
@@ -53,11 +57,14 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
selectedHostIds,
|
||||
onSelectHost,
|
||||
onSelectionChange,
|
||||
selectedGroupPaths = [],
|
||||
onGroupSelectionChange,
|
||||
targetsAllHosts = false,
|
||||
onTargetsAllHostsChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||
const [groupPickerOpen, setGroupPickerOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
@@ -81,9 +88,10 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onChange({
|
||||
...snippet,
|
||||
targets: selectedHostIds,
|
||||
targetGroups: resolveSnippetTargetGroupsForSave(snippet, selectedGroupPaths),
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}, [onChange, selectedHostIds, snippet]);
|
||||
}, [onChange, selectedGroupPaths, selectedHostIds, snippet]);
|
||||
|
||||
const language = 'javascript';
|
||||
const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE;
|
||||
@@ -146,9 +154,13 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
variant="embedded"
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
targetGroups={selectedGroupPaths}
|
||||
onEditTargets={() => {
|
||||
if (!targetsAllHosts) setTargetPickerOpen(true);
|
||||
}}
|
||||
onEditGroups={onGroupSelectionChange ? () => {
|
||||
if (!targetsAllHosts) setGroupPickerOpen(true);
|
||||
} : undefined}
|
||||
hint={t('scripts.targets.hint')}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
onTargetsAllHostsChange={onTargetsAllHostsChange}
|
||||
@@ -192,6 +204,21 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleTargetsConfirm}
|
||||
/>
|
||||
<SelectGroupDialog
|
||||
open={groupPickerOpen}
|
||||
onOpenChange={setGroupPickerOpen}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
selectedGroupPaths={selectedGroupPaths}
|
||||
onSelectionChange={(nextGroups) => {
|
||||
onGroupSelectionChange?.(nextGroups);
|
||||
onChange({
|
||||
...snippet,
|
||||
targetGroups: nextGroups,
|
||||
targetsAllHosts: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -98,6 +98,8 @@ interface SettingsSystemTabProps {
|
||||
setRestorePreviousSession: (enabled: boolean) => void;
|
||||
restoreTerminalCwd: boolean;
|
||||
setRestoreTerminalCwd: (enabled: boolean) => void;
|
||||
startupLanding: "vault" | "local-terminal";
|
||||
setStartupLanding: (landing: "vault" | "local-terminal") => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
@@ -139,6 +141,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setRestorePreviousSession,
|
||||
restoreTerminalCwd,
|
||||
setRestoreTerminalCwd,
|
||||
startupLanding,
|
||||
setStartupLanding,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
@@ -918,6 +922,24 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
|
||||
<SectionHeader title={t("settings.sessionRestore.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
<SettingRow
|
||||
anchorId="system-startup-landing"
|
||||
label={t("settings.sessionRestore.startupLanding")}
|
||||
description={t("settings.sessionRestore.startupLandingDesc")}
|
||||
>
|
||||
<Select
|
||||
value={startupLanding}
|
||||
onChange={(value) => {
|
||||
if (value === "vault" || value === "local-terminal") {
|
||||
setStartupLanding(value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: "vault", label: t("settings.sessionRestore.startupLanding.vault") },
|
||||
{ value: "local-terminal", label: t("settings.sessionRestore.startupLanding.localTerminal") },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="system-session-restore"
|
||||
label={t("settings.sessionRestore.restorePreviousSession")}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
|
||||
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
@@ -150,7 +151,7 @@ export const ModelSelector: React.FC<{
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${effectiveModelsEndpoint}`;
|
||||
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
|
||||
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
|
||||
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -509,6 +509,9 @@ export const ProviderConfigForm: React.FC<{
|
||||
placeholder={preset?.defaultBaseURL || "https://"}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
{resolvedStyle === "anthropic" ? (
|
||||
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Default Model */}
|
||||
|
||||
@@ -23,3 +23,17 @@ test("offers replace when a file upload conflicts with an existing file", () =>
|
||||
existingType: "file",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("offers replace when a directory upload conflicts with an existing symlink", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: true,
|
||||
existingType: "symlink",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("offers replace when a file upload conflicts with an existing symlink", () => {
|
||||
assert.equal(canReplaceConflict({
|
||||
isDirectory: false,
|
||||
existingType: "symlink",
|
||||
}), true);
|
||||
});
|
||||
@@ -50,7 +50,7 @@ test("sftp host picker uses single-line quick switcher row layout", () => {
|
||||
assert.doesNotMatch(source, /text-xs text-muted-foreground truncate/);
|
||||
});
|
||||
|
||||
test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
test("sftp host picker passes sourceSessionId for sudo connected hosts", () => {
|
||||
assert.match(source, /sftpSourceSessionIdForHost/);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -60,11 +60,6 @@ test("sftp host picker omits sourceSessionId for sudo connected hosts", () => {
|
||||
source,
|
||||
/onSelectHost\(\s*item\.entry\.host,\s*sourceSessionId \? \{ sourceSessionId \} : undefined,\s*\)/,
|
||||
);
|
||||
// Must not always pass sessionId as a reuse hint (sudo would hide the spinner).
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onSelectHost\(item\.entry\.host, \{ sourceSessionId: item\.entry\.sessionId \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sftp host picker virtualizes the host list", () => {
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import {
|
||||
connectionKeyMatchesHost,
|
||||
findReusableSftpSidePanelTab,
|
||||
isPendingSameEndpointSshSession,
|
||||
isRemoteSftpTabHealthy,
|
||||
rememberSftpSidePanelSourceStatus,
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate,
|
||||
shouldAcceptPendingSftpUpload,
|
||||
shouldDeferSftpSidePanelAutoConnectForSession,
|
||||
shouldRebindSftpSidePanelSourceSession,
|
||||
shouldResetSftpSidePanelSourceSession,
|
||||
shouldSkipSftpSidePanelAutoConnect,
|
||||
} from "./sftpSidePanelAutoConnect";
|
||||
@@ -77,6 +82,35 @@ test("shouldSkipSftpSidePanelAutoConnect rejects when the active tab has no endp
|
||||
);
|
||||
});
|
||||
|
||||
test("isPendingSameEndpointSshSession only waits for actively connecting SSH sessions", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
};
|
||||
const baseSession = {
|
||||
hostId: "host-1",
|
||||
hostname: "server.example",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connecting" }, host),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "disconnected" }, host),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isPendingSameEndpointSshSession({ ...baseSession, status: "connected" }, host),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("connectionKeyMatchesHost accepts host-id prefix keys", () => {
|
||||
assert.equal(connectionKeyMatchesHost("host-1:server:22:ssh::root", "host-1"), true);
|
||||
assert.equal(connectionKeyMatchesHost("host-2:server:22:ssh::root", "host-1"), false);
|
||||
@@ -146,6 +180,175 @@ test("shouldResetSftpSidePanelSourceSession detects terminal session changes", (
|
||||
assert.equal(shouldResetSftpSidePanelSourceSession("sess-a", null), false);
|
||||
});
|
||||
|
||||
test("shouldRebindSftpSidePanelSourceSession treats SSH start-over as a transport change", () => {
|
||||
// Same terminal tab id after Start over - transport was replaced even though
|
||||
// the session id did not change.
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "disconnected",
|
||||
nextStatus: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "connecting",
|
||||
nextStatus: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "connected",
|
||||
nextStatus: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-b",
|
||||
previousStatus: "connected",
|
||||
nextStatus: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "disconnected",
|
||||
nextStatus: "connecting",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldDeferSftpSidePanelAutoConnectForSession only waits during an active reconnect", () => {
|
||||
assert.equal(
|
||||
shouldDeferSftpSidePanelAutoConnectForSession({
|
||||
activeSessionId: "sess-a",
|
||||
sessionStatus: "disconnected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDeferSftpSidePanelAutoConnectForSession({
|
||||
activeSessionId: "sess-a",
|
||||
sessionStatus: "connecting",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDeferSftpSidePanelAutoConnectForSession({
|
||||
activeSessionId: "sess-a",
|
||||
sessionStatus: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDeferSftpSidePanelAutoConnectForSession({
|
||||
activeSessionId: null,
|
||||
sessionStatus: "connecting",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSftpSidePanelTrackedSourceStatusUpdate remembers background disconnects", () => {
|
||||
assert.deepEqual(
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate({
|
||||
trackedSessionId: "sess-a",
|
||||
sessionStatus: "disconnected",
|
||||
}),
|
||||
{ sessionId: "sess-a", status: "disconnected" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate({
|
||||
trackedSessionId: "sess-a",
|
||||
sessionStatus: "connecting",
|
||||
}),
|
||||
{ sessionId: "sess-a", status: "connecting" },
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate({
|
||||
trackedSessionId: "sess-a",
|
||||
sessionStatus: "connected",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpSidePanelTrackedSourceStatusUpdate({
|
||||
trackedSessionId: null,
|
||||
sessionStatus: "disconnected",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("rememberSftpSidePanelSourceStatus keeps the linked SSH status across non-SSH focus", () => {
|
||||
assert.equal(
|
||||
rememberSftpSidePanelSourceStatus({
|
||||
previousStatus: "connecting",
|
||||
activeSessionId: null,
|
||||
activeSessionStatus: null,
|
||||
}),
|
||||
"connecting",
|
||||
);
|
||||
assert.equal(
|
||||
rememberSftpSidePanelSourceStatus({
|
||||
previousStatus: "disconnected",
|
||||
activeSessionId: null,
|
||||
activeSessionStatus: null,
|
||||
}),
|
||||
"disconnected",
|
||||
);
|
||||
assert.equal(
|
||||
rememberSftpSidePanelSourceStatus({
|
||||
previousStatus: "disconnected",
|
||||
activeSessionId: "sess-a",
|
||||
activeSessionStatus: "connected",
|
||||
}),
|
||||
"connected",
|
||||
);
|
||||
});
|
||||
|
||||
test("failed terminal reconnect keeps a healthy standalone SFTP tab", () => {
|
||||
const tab = remoteConnectedTab();
|
||||
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "connecting",
|
||||
nextStatus: "disconnected",
|
||||
});
|
||||
assert.equal(sessionChanged, false);
|
||||
assert.equal(
|
||||
!sessionChanged && shouldSkipSftpSidePanelAutoConnect(
|
||||
"host-1:key",
|
||||
"host-1:key",
|
||||
tab,
|
||||
true,
|
||||
"host-1:key",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRebindSftpSidePanelSourceSession({
|
||||
previousSessionId: "sess-a",
|
||||
nextSessionId: "sess-a",
|
||||
previousStatus: "connecting",
|
||||
nextStatus: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("session change still requires rebind even when the endpoint key matches", () => {
|
||||
const tab = remoteConnectedTab();
|
||||
// Callers must not skip auto-connect solely because the tab is healthy —
|
||||
|
||||
@@ -33,6 +33,36 @@ export function shouldSkipSftpSidePanelAutoConnect(
|
||||
return isRemoteSftpTabHealthy(activeTab, hasBackendSession);
|
||||
}
|
||||
|
||||
export function isPendingSameEndpointSshSession(
|
||||
session: {
|
||||
hostId?: string | null;
|
||||
status?: string;
|
||||
hostname?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
protocol?: string | null;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
},
|
||||
host: {
|
||||
id: string;
|
||||
hostname: string;
|
||||
port?: number | null;
|
||||
username: string;
|
||||
},
|
||||
): boolean {
|
||||
return Boolean(
|
||||
session.hostId === host.id
|
||||
&& session.status === "connecting"
|
||||
&& session.hostname === host.hostname
|
||||
&& (session.port ?? 22) === (host.port ?? 22)
|
||||
&& session.username === host.username
|
||||
&& (session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a stored endpoint key still belongs to the live connection's host. */
|
||||
export function connectionKeyMatchesHost(
|
||||
connectionKey: string | null | undefined,
|
||||
@@ -105,3 +135,66 @@ export function shouldResetSftpSidePanelSourceSession(
|
||||
if (!previousSessionId) return false;
|
||||
return nextSessionId !== previousSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the SFTP side panel must rebind onto a fresh SSH transport.
|
||||
* Covers focus switches (session id change) and same-tab reconnect /
|
||||
* Start over, where the id is stable but the underlying channel was replaced.
|
||||
*/
|
||||
export function shouldRebindSftpSidePanelSourceSession(params: {
|
||||
previousSessionId: string | null | undefined;
|
||||
nextSessionId: string | null | undefined;
|
||||
previousStatus?: string | null;
|
||||
nextStatus?: string | null;
|
||||
}): boolean {
|
||||
if (shouldResetSftpSidePanelSourceSession(params.previousSessionId, params.nextSessionId)) {
|
||||
return true;
|
||||
}
|
||||
if (!params.nextSessionId) return false;
|
||||
if (params.previousSessionId !== params.nextSessionId) return false;
|
||||
if (params.nextStatus !== "connected") return false;
|
||||
if (params.previousStatus == null) return false;
|
||||
return params.previousStatus !== "connected";
|
||||
}
|
||||
|
||||
/**
|
||||
* While the linked terminal is actively connecting, wait for its transport so
|
||||
* SFTP can reuse it. A terminal left disconnected must not block standalone
|
||||
* SFTP fallback.
|
||||
*/
|
||||
export function shouldDeferSftpSidePanelAutoConnectForSession(params: {
|
||||
activeSessionId?: string | null;
|
||||
sessionStatus?: string | null;
|
||||
}): boolean {
|
||||
if (!params.activeSessionId) return false;
|
||||
return params.sessionStatus === "connecting";
|
||||
}
|
||||
|
||||
/**
|
||||
* While the last linked SSH session is not the active reusable source,
|
||||
* remember its non-connected status so a background reconnect still rebinds
|
||||
* when that session becomes active again.
|
||||
*/
|
||||
export function resolveSftpSidePanelTrackedSourceStatusUpdate(params: {
|
||||
trackedSessionId?: string | null;
|
||||
sessionStatus?: string | null;
|
||||
}): { sessionId: string; status: string } | null {
|
||||
if (!params.trackedSessionId) return null;
|
||||
if (!params.sessionStatus || params.sessionStatus === "connected") return null;
|
||||
return {
|
||||
sessionId: params.trackedSessionId,
|
||||
status: params.sessionStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the tracked source status when focus temporarily has no reusable SSH. */
|
||||
export function rememberSftpSidePanelSourceStatus(params: {
|
||||
previousStatus?: string | null;
|
||||
activeSessionId?: string | null;
|
||||
activeSessionStatus?: string | null;
|
||||
}): string | null {
|
||||
if (params.activeSessionId && params.activeSessionStatus) {
|
||||
return params.activeSessionStatus;
|
||||
}
|
||||
return params.previousStatus ?? null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
|
||||
const tabBridgeSource = readFileSync(
|
||||
new URL("../terminalLayer/TerminalLayerTabBridge.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("SFTP side panel rebinds after same-tab SSH start-over", () => {
|
||||
assert.match(sidePanelSource, /shouldRebindSftpSidePanelSourceSession\(/);
|
||||
assert.match(sidePanelSource, /shouldDeferSftpSidePanelAutoConnectForSession\(/);
|
||||
assert.match(sidePanelSource, /lastSourceSessionStatusRef/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
|
||||
);
|
||||
// Reuse only after SSH is connected; linked id may arrive while reconnecting.
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/sourceSessionId:\s*activeSessionStatus === "connected"/,
|
||||
);
|
||||
assert.match(sidePanelSource, /resolveSftpSidePanelTrackedSourceStatusUpdate\(/);
|
||||
assert.match(
|
||||
sidePanelSource,
|
||||
/trackedSessionId = lastSourceSessionIdRef\.current/,
|
||||
);
|
||||
assert.match(sidePanelSource, /if \(activeSessionId\) return;/);
|
||||
});
|
||||
|
||||
test("terminal layer keeps the linked SFTP session id while SSH reconnects", () => {
|
||||
assert.match(tabBridgeSource, /isTerminalSessionEligibleForSftpReuse\(session\)/);
|
||||
assert.doesNotMatch(
|
||||
tabBridgeSource,
|
||||
/activeTerminalSessionIdForSftp[\s\S]*canReuseTerminalConnection\(session\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SnippetTargetsSection } from './SnippetTargetsSection';
|
||||
|
||||
const t = (key: string) => ({
|
||||
'snippets.targets.title': 'Targets',
|
||||
'snippets.targets.selectHosts': 'Select hosts',
|
||||
'snippets.targets.selectGroups': 'Select groups',
|
||||
'snippets.targets.add': 'Add targets',
|
||||
'snippets.targets.allHosts': 'All hosts',
|
||||
'snippets.targets.allHostsShort': 'All hosts',
|
||||
'snippets.targets.allHostsActive': 'Every host',
|
||||
}[key] ?? key);
|
||||
|
||||
test('renders dynamic group targets separately from explicit hosts', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production/Web']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.match(markup, /Production\/Web/);
|
||||
assert.match(markup, /Select hosts/);
|
||||
assert.match(markup, /Select groups/);
|
||||
});
|
||||
|
||||
test('all-host mode hides host and group pickers', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SnippetTargetsSection
|
||||
t={t}
|
||||
targetHosts={[]}
|
||||
targetGroups={['Production']}
|
||||
onEditTargets={() => undefined}
|
||||
onEditGroups={() => undefined}
|
||||
targetsAllHosts
|
||||
onTargetsAllHostsChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
assert.doesNotMatch(markup, /Select hosts/);
|
||||
assert.doesNotMatch(markup, /Select groups/);
|
||||
assert.match(markup, /Every host/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FolderTree, Server } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
variant?: 'card' | 'embedded';
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -25,6 +28,8 @@ const TargetsBody: React.FC<{
|
||||
t: SnippetTargetsSectionProps['t'];
|
||||
targetHosts: Host[];
|
||||
onEditTargets: () => void;
|
||||
targetGroups?: string[];
|
||||
onEditGroups?: () => void;
|
||||
hint?: string;
|
||||
embedded?: boolean;
|
||||
targetsAllHosts?: boolean;
|
||||
@@ -33,6 +38,8 @@ const TargetsBody: React.FC<{
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups = [],
|
||||
onEditGroups,
|
||||
hint,
|
||||
embedded = false,
|
||||
targetsAllHosts = false,
|
||||
@@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
|
||||
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{!targetsAllHosts ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
{targetHosts.length > 0 ? t('action.edit') : t('snippets.targets.add')}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditTargets}
|
||||
>
|
||||
<Server size={12} />
|
||||
{t('snippets.targets.selectHosts')}
|
||||
</Button>
|
||||
{onEditGroups ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(actionButtonClass(embedded), 'text-primary gap-1')}
|
||||
onClick={onEditGroups}
|
||||
>
|
||||
<FolderTree size={12} />
|
||||
{t('snippets.targets.selectGroups')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onTargetsAllHostsChange ? (
|
||||
@@ -91,7 +112,7 @@ const TargetsBody: React.FC<{
|
||||
>
|
||||
{t('snippets.targets.allHostsActive')}
|
||||
</p>
|
||||
) : targetHosts.length === 0 ? (
|
||||
) : targetHosts.length === 0 && targetGroups.length === 0 ? (
|
||||
embedded ? null : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -103,6 +124,18 @@ const TargetsBody: React.FC<{
|
||||
)
|
||||
) : (
|
||||
<div className={cn('gap-2', embedded ? 'flex flex-wrap' : 'space-y-2')}>
|
||||
{targetGroups.map((groupPath) => (
|
||||
<div
|
||||
key={`group:${groupPath}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border border-border/60 bg-primary/5 max-w-full',
|
||||
embedded ? 'px-2 py-1.5 text-xs' : 'px-3 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
<FolderTree size={14} className="text-primary shrink-0" />
|
||||
<span className="truncate font-medium">{groupPath}</span>
|
||||
</div>
|
||||
))}
|
||||
{targetHosts.map((host) => (
|
||||
embedded ? (
|
||||
<div
|
||||
@@ -133,6 +166,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t,
|
||||
targetHosts,
|
||||
onEditTargets,
|
||||
targetGroups,
|
||||
onEditGroups,
|
||||
hint,
|
||||
variant = 'card',
|
||||
targetsAllHosts,
|
||||
@@ -143,6 +178,8 @@ export const SnippetTargetsSection: React.FC<SnippetTargetsSectionProps> = ({
|
||||
t={t}
|
||||
targetHosts={targetHosts}
|
||||
onEditTargets={onEditTargets}
|
||||
targetGroups={targetGroups}
|
||||
onEditGroups={onEditGroups}
|
||||
hint={hint}
|
||||
embedded={variant === 'embedded'}
|
||||
targetsAllHosts={targetsAllHosts}
|
||||
|
||||
@@ -64,6 +64,16 @@ test("overview tab reuses the shared server stats source", () => {
|
||||
assert.doesNotMatch(source, /backend\.getServerStats/);
|
||||
});
|
||||
|
||||
test("overview network interfaces show cumulative RX/TX totals alongside rates", () => {
|
||||
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /stats\.netInterfaces\.slice\(0, 5\)\.map/);
|
||||
assert.match(source, /formatBytes\(iface\.rxBytes\)/);
|
||||
assert.match(source, /formatBytes\(iface\.txBytes\)/);
|
||||
assert.match(source, /formatThroughput\(iface\.rxSpeed\)/);
|
||||
assert.match(source, /formatThroughput\(iface\.txSpeed\)/);
|
||||
});
|
||||
|
||||
test("overview tab stays mounted and only pauses polling while another system tab is active", () => {
|
||||
const source = readFileSync(new URL("./SystemManagerSidePanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function formatPercent(value: number | null | undefined, digits = 0): string {
|
||||
function formatBytes(bytes: number): string {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)} TB`;
|
||||
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)} GB`;
|
||||
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
@@ -396,11 +397,16 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
|
||||
{stats.netInterfaces.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.netInterfaces.slice(0, 5).map((iface) => (
|
||||
<div key={iface.name} className="grid grid-cols-[1fr_auto] items-center gap-2 text-[11px]">
|
||||
<span className="min-w-0 truncate text-foreground">{iface.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
<div key={iface.name} className="space-y-0.5">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2 text-[11px]">
|
||||
<span className="min-w-0 truncate text-foreground">{iface.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{t('systemManager.overview.rx')} {formatBytes(iface.rxBytes)} · {t('systemManager.overview.tx')} {formatBytes(iface.txBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-[10px] tabular-nums text-muted-foreground/80">
|
||||
{t('systemManager.overview.rx')} {formatThroughput(iface.rxSpeed)} · {t('systemManager.overview.tx')} {formatThroughput(iface.txSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
@@ -63,6 +64,7 @@ export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
@@ -119,6 +121,7 @@ export function TerminalAutocomplete({
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
},
|
||||
hostOs,
|
||||
hostGroup,
|
||||
cwdSource: options.cwdSource,
|
||||
snippets: options.snippets,
|
||||
maximum: options.maxResults ?? 15,
|
||||
@@ -126,12 +129,13 @@ export function TerminalAutocomplete({
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityGb, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -263,7 +263,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
|
||||
? `${formatDiskCapacityGb(diskSummary.used)}/${formatDiskCapacityGb(diskSummary.total)}G (${diskSummary.percent}%)`
|
||||
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
|
||||
: diskSummary.percent !== null
|
||||
? `${diskSummary.percent}%`
|
||||
: '--'}
|
||||
@@ -295,7 +295,7 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{formatDiskCapacityGb(disk.used)}/{formatDiskCapacityGb(disk.total)}G ({disk.percent}%)
|
||||
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
|
||||
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\)/);
|
||||
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
|
||||
@@ -993,6 +993,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostGroup={host.group}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={effectiveTerminalProtocol ?? resolveEffectiveTerminalProtocol(host)}
|
||||
|
||||
@@ -242,6 +242,7 @@ export async function getCompletions(
|
||||
input: string,
|
||||
options: {
|
||||
hostId?: string;
|
||||
hostGroup?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
maxResults?: number;
|
||||
/** Session ID for remote path completion */
|
||||
@@ -397,7 +398,10 @@ export async function getCompletions(
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, { hostId })) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,31 +7,37 @@
|
||||
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function snippetAvailableForAutocomplete(snippet: Snippet, hostId?: string): boolean {
|
||||
function snippetAvailableForAutocomplete(
|
||||
snippet: Snippet,
|
||||
host: { hostId?: string; hostGroup?: string },
|
||||
): boolean {
|
||||
if (snippet.targetsAllHosts) return true;
|
||||
if (snippet.targets && snippet.targets.length > 0) {
|
||||
return hostId !== undefined && snippet.targets.includes(hostId);
|
||||
}
|
||||
return true;
|
||||
const hasScopedTargets = Boolean(
|
||||
snippet.targets?.length || snippet.targetGroups !== undefined,
|
||||
);
|
||||
if (!hasScopedTargets) return true;
|
||||
if (!host.hostId) return false;
|
||||
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string } = {},
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!snippetAvailableForAutocomplete(snippet, options.hostId)) continue;
|
||||
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
|
||||
export interface TerminalCompletionProviderRequest {
|
||||
input: string;
|
||||
session: NetcattyTerminalSessionSnapshot;
|
||||
hostGroup?: string;
|
||||
hostOs: 'linux' | 'windows' | 'macos';
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
snippets?: Snippet[];
|
||||
@@ -63,6 +64,7 @@ export async function provideTerminalCompletions(
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const builtInPromise = getCompletions(request.input, {
|
||||
hostId: request.session.hostId,
|
||||
hostGroup: request.hostGroup,
|
||||
os: request.hostOs,
|
||||
maxResults: request.maximum,
|
||||
sessionId: request.session.sessionId,
|
||||
|
||||
@@ -149,6 +149,7 @@ interface UseTerminalAutocompleteOptions {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
/** Callback to write text to the terminal session — replaces CustomEvent */
|
||||
@@ -200,6 +201,7 @@ export function useTerminalAutocomplete(
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings: userSettings,
|
||||
onAcceptText,
|
||||
@@ -237,6 +239,8 @@ export function useTerminalAutocomplete(
|
||||
onAcceptSnippetRef.current = onAcceptSnippet;
|
||||
const hostIdRef = useRef(hostId);
|
||||
hostIdRef.current = hostId;
|
||||
const hostGroupRef = useRef(hostGroup);
|
||||
hostGroupRef.current = hostGroup;
|
||||
const hostOsRef = useRef(hostOs);
|
||||
hostOsRef.current = hostOs;
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
@@ -1048,6 +1052,7 @@ export function useTerminalAutocomplete(
|
||||
try {
|
||||
completions = await provideCompletionsRef.current(input, {
|
||||
hostId: hostIdRef.current,
|
||||
hostGroup: hostGroupRef.current,
|
||||
os: hostOsRef.current,
|
||||
maxResults: settingsRef.current.maxSuggestions,
|
||||
historyScope: settingsRef.current.historyScope,
|
||||
|
||||
@@ -19,6 +19,27 @@ test("getCompletions does not surface snippets past the command position", async
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions applies dynamic group targets to the current host", async () => {
|
||||
const groupedSnippet: Snippet = {
|
||||
...deploySnippet,
|
||||
targetGroups: ["Production"],
|
||||
};
|
||||
|
||||
const matching = await getCompletions("dep", {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
const outside = await getCompletions("dep", {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
|
||||
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
|
||||
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
|
||||
});
|
||||
|
||||
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CONNECTION_PROGRESS_CAP,
|
||||
CONNECTION_PROGRESS_START,
|
||||
advanceIndeterminateConnectionProgress,
|
||||
advanceMonotonicConnectionProgress,
|
||||
resolveHopConnectionProgress,
|
||||
} from "./connectionProgress";
|
||||
|
||||
test("indeterminate connection progress only moves forward toward the cap", () => {
|
||||
assert.equal(advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_CAP), CONNECTION_PROGRESS_CAP);
|
||||
assert.equal(advanceIndeterminateConnectionProgress(94), CONNECTION_PROGRESS_CAP);
|
||||
const first = advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_START);
|
||||
assert.ok(first > CONNECTION_PROGRESS_START);
|
||||
assert.ok(first < CONNECTION_PROGRESS_CAP);
|
||||
const second = advanceIndeterminateConnectionProgress(first);
|
||||
assert.ok(second > first);
|
||||
});
|
||||
|
||||
test("hop connection progress stays within the visual cap", () => {
|
||||
assert.equal(resolveHopConnectionProgress(1, 1), 90);
|
||||
assert.equal(resolveHopConnectionProgress(1, 2), 50);
|
||||
assert.equal(resolveHopConnectionProgress(2, 2), 90);
|
||||
assert.equal(resolveHopConnectionProgress(0, 1), 10);
|
||||
});
|
||||
|
||||
test("connection progress updates never rewind", () => {
|
||||
assert.equal(advanceMonotonicConnectionProgress(40, 10), 40);
|
||||
assert.equal(advanceMonotonicConnectionProgress(40, 90), 90);
|
||||
assert.equal(advanceMonotonicConnectionProgress(5, 5), 5);
|
||||
});
|
||||
|
||||
test("connecting seeds progress once and hop/phase changes do not snap it back", () => {
|
||||
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/if \(status === "connecting"\) \{\s*setIsDisconnectedDialogDismissed\(false\);\s*setProgressValue\(CONNECTION_PROGRESS_START\);/,
|
||||
);
|
||||
assert.match(effectsSource, /setProgressValue\(advanceIndeterminateConnectionProgress\)/);
|
||||
assert.doesNotMatch(effectsSource, /setProgressValue\(5\)/);
|
||||
|
||||
const starterSource = readFileSync(
|
||||
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(starterSource, /advanceMonotonicConnectionProgress/);
|
||||
assert.match(starterSource, /resolveHopConnectionProgress/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export const CONNECTION_PROGRESS_START = 5;
|
||||
export const CONNECTION_PROGRESS_CAP = 95;
|
||||
|
||||
export const advanceIndeterminateConnectionProgress = (prev: number): number => {
|
||||
if (prev >= CONNECTION_PROGRESS_CAP) return prev;
|
||||
const remaining = CONNECTION_PROGRESS_CAP - prev;
|
||||
const increment = Math.max(1, remaining * 0.15);
|
||||
return Math.min(CONNECTION_PROGRESS_CAP, prev + increment);
|
||||
};
|
||||
|
||||
export const resolveHopConnectionProgress = (hop: number, total: number): number => {
|
||||
const safeTotal = Math.max(1, total);
|
||||
const safeHop = Math.max(0, hop);
|
||||
return Math.min(CONNECTION_PROGRESS_CAP, (safeHop / safeTotal) * 80 + 10);
|
||||
};
|
||||
|
||||
export const advanceMonotonicConnectionProgress = (prev: number, next: number): number =>
|
||||
Math.max(prev, next);
|
||||
@@ -1,12 +1,22 @@
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type SearchMatchCount = { current: number; total: number } | null;
|
||||
|
||||
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
|
||||
type TerminalSearchVisualElement = {
|
||||
querySelectorAll: (selector: string) => ArrayLike<{ remove: () => void }>;
|
||||
};
|
||||
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null;
|
||||
type TerminalSearchGuardTarget = TerminalSearchResetTarget;
|
||||
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
@@ -16,6 +26,58 @@ const SEARCH_DECORATIONS = {
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
} as const;
|
||||
|
||||
const SEARCH_DECORATION_BACKGROUNDS = new Set<string>([
|
||||
SEARCH_DECORATIONS.matchBackground.toLowerCase(),
|
||||
SEARCH_DECORATIONS.activeMatchBackground.toLowerCase(),
|
||||
]);
|
||||
|
||||
type StaleSearchDecoration = {
|
||||
dispose: () => void;
|
||||
options?: { backgroundColor?: string };
|
||||
element?: {
|
||||
classList?: { contains: (name: string) => boolean };
|
||||
style?: { backgroundColor?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type CellDecorationService = {
|
||||
decorations?: Iterable<StaleSearchDecoration>;
|
||||
forEachDecorationAtCell?: (
|
||||
x: number,
|
||||
y: number,
|
||||
layer: "bottom" | "top" | undefined,
|
||||
callback: (decoration: StaleSearchDecoration) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type SearchAddonInternals = {
|
||||
clearDecorations?: () => void;
|
||||
clearActiveDecoration?: () => void;
|
||||
_highlightTimeout?: { clear?: () => void };
|
||||
_state?: { reset?: () => void };
|
||||
};
|
||||
|
||||
type TerminalDecorationHost = {
|
||||
_core?: { _decorationService?: CellDecorationService };
|
||||
_decorationService?: CellDecorationService;
|
||||
};
|
||||
|
||||
export const SEARCH_DECORATION_TRACKER_KEY = "__netcattySearchDecorationTracker";
|
||||
|
||||
export type SearchDecorationTracker = {
|
||||
disposeAll: () => number;
|
||||
size: () => number;
|
||||
markSearched: () => void;
|
||||
hasSearched: () => boolean;
|
||||
consumeSearched: () => boolean;
|
||||
noteEmptyQueryReset: () => void;
|
||||
consumeCloseSweep: () => boolean;
|
||||
};
|
||||
|
||||
type TrackableTerminal = Pick<XTerm, "registerDecoration"> & {
|
||||
[SEARCH_DECORATION_TRACKER_KEY]?: SearchDecorationTracker;
|
||||
};
|
||||
|
||||
const SEARCH_OPTIONS = {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
@@ -23,20 +85,266 @@ const SEARCH_OPTIONS = {
|
||||
decorations: SEARCH_DECORATIONS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
|
||||
* not cancel that timer from `clearDecorations()`. A timeout that already
|
||||
* captured the prior term can revive yellow match decorations after reset —
|
||||
* re-clear once past that window (issue #2980).
|
||||
*/
|
||||
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
|
||||
|
||||
/**
|
||||
* SearchAddon paints matches as HTML overlays (`.xterm-find-result-decoration`).
|
||||
* Disposing the addon decoration does not always detach that node — after Esc
|
||||
* closes the search bar and the terminal refits, the first two cells of the
|
||||
* last hit can stay outlined on top of the buffer.
|
||||
*/
|
||||
export const SEARCH_DECORATION_NODE_SELECTOR =
|
||||
".xterm-find-result-decoration, .xterm-find-active-result-decoration";
|
||||
|
||||
export const stripStaleSearchDecorationNodes = (
|
||||
term?: { element?: TerminalSearchVisualElement | null } | null,
|
||||
): void => {
|
||||
const nodes = term?.element?.querySelectorAll(SEARCH_DECORATION_NODE_SELECTOR);
|
||||
if (!nodes) return;
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
nodes[i]?.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export const isSearchDecorationBackground = (color?: string): boolean => (
|
||||
Boolean(color) && SEARCH_DECORATION_BACKGROUNDS.has(color.trim().toLowerCase())
|
||||
);
|
||||
|
||||
export const installSearchDecorationTracker = (
|
||||
term: TrackableTerminal,
|
||||
): SearchDecorationTracker => {
|
||||
const existing = term[SEARCH_DECORATION_TRACKER_KEY];
|
||||
if (existing) return existing;
|
||||
|
||||
const tracked = new Set<{ dispose: () => void }>();
|
||||
let searched = false;
|
||||
let pendingCloseSweep = false;
|
||||
const originalRegister = term.registerDecoration.bind(term);
|
||||
term.registerDecoration = (options) => {
|
||||
// Keep search fill on the HTML overlay only. Passing backgroundColor into
|
||||
// xterm lets WebGL bake the yellow into the glyph atlas, and those cells
|
||||
// stay stained after Esc even when the decoration handle is gone.
|
||||
const searchBackground = isSearchDecorationBackground(options.backgroundColor)
|
||||
? options.backgroundColor
|
||||
: undefined;
|
||||
const decoration = originalRegister(
|
||||
searchBackground ? { ...options, backgroundColor: undefined } : options,
|
||||
);
|
||||
if (!decoration) return decoration;
|
||||
if (!searchBackground) return decoration;
|
||||
tracked.add(decoration);
|
||||
decoration.onRender((element) => {
|
||||
element.style.backgroundColor = searchBackground;
|
||||
});
|
||||
decoration.onDispose(() => {
|
||||
tracked.delete(decoration);
|
||||
});
|
||||
return decoration;
|
||||
};
|
||||
|
||||
const tracker: SearchDecorationTracker = {
|
||||
disposeAll: () => {
|
||||
const leftover = [...tracked];
|
||||
tracked.clear();
|
||||
for (const decoration of leftover) decoration.dispose();
|
||||
return leftover.length;
|
||||
},
|
||||
size: () => tracked.size,
|
||||
markSearched: () => {
|
||||
searched = true;
|
||||
pendingCloseSweep = true;
|
||||
},
|
||||
hasSearched: () => searched,
|
||||
consumeSearched: () => {
|
||||
const value = searched;
|
||||
searched = false;
|
||||
return value;
|
||||
},
|
||||
noteEmptyQueryReset: () => {
|
||||
searched = false;
|
||||
},
|
||||
consumeCloseSweep: () => {
|
||||
const value = pendingCloseSweep;
|
||||
pendingCloseSweep = false;
|
||||
return value;
|
||||
},
|
||||
};
|
||||
term[SEARCH_DECORATION_TRACKER_KEY] = tracker;
|
||||
return tracker;
|
||||
};
|
||||
|
||||
export const getSearchDecorationTracker = (term?: unknown): SearchDecorationTracker | null => {
|
||||
if (!term || typeof term !== "object") return null;
|
||||
const tracker = (term as TrackableTerminal)[SEARCH_DECORATION_TRACKER_KEY];
|
||||
return tracker && typeof tracker.disposeAll === "function" ? tracker : null;
|
||||
};
|
||||
|
||||
const isStaleSearchDecoration = (decoration: StaleSearchDecoration): boolean => (
|
||||
isSearchDecorationBackground(decoration.options?.backgroundColor)
|
||||
|| isSearchDecorationBackground(decoration.element?.style?.backgroundColor)
|
||||
|| decoration.element?.classList?.contains("xterm-find-result-decoration") === true
|
||||
|| decoration.element?.classList?.contains("xterm-find-active-result-decoration") === true
|
||||
);
|
||||
|
||||
const readDecorationService = (term?: unknown): CellDecorationService | null => {
|
||||
const host = term as TerminalDecorationHost & {
|
||||
_core?: Record<string, unknown>;
|
||||
} | null | undefined;
|
||||
const direct = host?._core?._decorationService ?? host?._decorationService;
|
||||
if (direct && (direct.decorations || direct.forEachDecorationAtCell)) {
|
||||
return direct;
|
||||
}
|
||||
const core = host?._core;
|
||||
if (!core || typeof core !== "object") return null;
|
||||
for (const value of Object.values(core)) {
|
||||
const candidate = value as CellDecorationService | undefined;
|
||||
if (candidate && typeof candidate.forEachDecorationAtCell === "function") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readDecorationIterable = (
|
||||
term?: unknown,
|
||||
): Iterable<StaleSearchDecoration> | null => (
|
||||
readDecorationService(term)?.decorations ?? null
|
||||
);
|
||||
|
||||
export const disposeSearchDecorationsInViewport = (term?: unknown): number => {
|
||||
const service = readDecorationService(term);
|
||||
const view = term as {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
buffer?: { active?: { viewportY?: number } };
|
||||
} | null | undefined;
|
||||
if (!service?.forEachDecorationAtCell || !view?.cols || !view.rows) return 0;
|
||||
const viewportY = view.buffer?.active?.viewportY ?? 0;
|
||||
const stale = new Set<StaleSearchDecoration>();
|
||||
for (let y = viewportY; y < viewportY + view.rows; y += 1) {
|
||||
for (let x = 0; x < view.cols; x += 1) {
|
||||
service.forEachDecorationAtCell(x, y, undefined, (decoration) => {
|
||||
if (isStaleSearchDecoration(decoration)) stale.add(decoration);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return stale.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebGL paints decoration backgroundColor into the cell. SearchAddon can lose
|
||||
* a couple of those handles on Esc+refit, so walk the terminal decoration
|
||||
* service and dispose anything still using the search yellow/orange.
|
||||
*/
|
||||
export const disposeStaleSearchDecorations = (term?: unknown): number => {
|
||||
if (term && typeof term === "object" && "registerDecoration" in term) {
|
||||
installSearchDecorationTracker(term as TrackableTerminal);
|
||||
}
|
||||
const trackedCount = getSearchDecorationTracker(term)?.disposeAll() ?? 0;
|
||||
const viewportCount = disposeSearchDecorationsInViewport(term);
|
||||
const decorations = readDecorationIterable(term);
|
||||
if (!decorations) return trackedCount + viewportCount;
|
||||
const stale: StaleSearchDecoration[] = [];
|
||||
for (const decoration of decorations) {
|
||||
if (isStaleSearchDecoration(decoration)) stale.push(decoration);
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return trackedCount + viewportCount + stale.length;
|
||||
};
|
||||
|
||||
/** Cancel SearchAddon's 200ms _updateMatches timer and drop cached term/options. */
|
||||
export const disarmSearchAddonRevival = (searchAddon?: unknown): void => {
|
||||
const addon = searchAddon as SearchAddonInternals | null | undefined;
|
||||
if (!addon) return;
|
||||
addon._highlightTimeout?.clear?.();
|
||||
addon._state?.reset?.();
|
||||
addon.clearActiveDecoration?.();
|
||||
addon.clearDecorations?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delayed re-clear for addon decoration revival only. Do not clearSelection
|
||||
* here: reset already cleared the search selection, and a user may have made
|
||||
* a new manual selection during the guard window.
|
||||
*/
|
||||
export const clearTerminalSearchHighlights = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: Pick<XTerm, "refresh" | "rows"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null,
|
||||
): void => {
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
disposeStaleSearchDecorations(term);
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
if (term && term.rows > 0) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* After the search bar unmounts the terminal grows and is force-fitted.
|
||||
* Re-sweep leftover overlays and the addon selection that a resize can revive
|
||||
* as a 2-cell sliver of the last match.
|
||||
*/
|
||||
export const settleTerminalSearchAfterLayout = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: TerminalSearchResetTarget,
|
||||
onRepaint?: () => void,
|
||||
): void => {
|
||||
// Search-open state is shared across terminals. A sibling that never
|
||||
// searched still sees the bar close and would otherwise lose a manual
|
||||
// selection via clearSelection(). Emptying the query resets highlights
|
||||
// while the bar stays open; keep the close-time repaint, but do not
|
||||
// treat that stale search as a reason to wipe a later manual selection.
|
||||
const tracker = getSearchDecorationTracker(term);
|
||||
const shouldClearSelection = tracker ? tracker.consumeSearched() : true;
|
||||
const shouldSweepLeftovers = tracker ? tracker.consumeCloseSweep() : true;
|
||||
const shouldSweep = !tracker || shouldClearSelection || shouldSweepLeftovers;
|
||||
if (!shouldSweep) return;
|
||||
clearTerminalSearchHighlights(searchAddon, term);
|
||||
if (shouldClearSelection) term?.clearSelection();
|
||||
term?.clearTextureAtlas?.();
|
||||
onRepaint?.();
|
||||
};
|
||||
|
||||
export const resetTerminalSearch = (
|
||||
searchAddon: Pick<SearchAddon, "findNext" | "clearDecorations"> | null,
|
||||
searchAddon: SearchAddonResetTarget,
|
||||
searchTermRef: { current: string },
|
||||
term?: Pick<XTerm, "refresh" | "rows"> | null,
|
||||
term?: TerminalSearchResetTarget,
|
||||
): void => {
|
||||
searchTermRef.current = "";
|
||||
// The addon's empty-search path clears both its decorations and the terminal
|
||||
// selection used for the active match. clearDecorations() alone leaves that
|
||||
// selection visible.
|
||||
searchAddon?.findNext("", SEARCH_OPTIONS);
|
||||
// findNext("") assigns cachedSearchTerm back to "". Clear decorations again
|
||||
// so the addon drops that cache to undefined and cannot revive match
|
||||
// highlights via its delayed onResize/onWriteParsed update path.
|
||||
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
|
||||
// `_updateMatches` timeout observes an empty cache and does not revive.
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
// clearDecorations() leaves the active-match selection; clear it explicitly.
|
||||
term?.clearSelection();
|
||||
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
|
||||
// findNext always assigns lastSearchOptions, and decoration options would
|
||||
// keep that latch armed for later write/resize updates.
|
||||
try {
|
||||
searchAddon?.findNext("");
|
||||
} catch {
|
||||
// Addon not activated yet.
|
||||
}
|
||||
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
|
||||
// is undefined rather than an empty string.
|
||||
searchAddon?.clearDecorations();
|
||||
// SearchAddon can drop a couple of decoration handles on Esc+refit. Those
|
||||
// leftover yellow cells are still in xterm's decoration service and WebGL
|
||||
// keeps painting them until we dispose them directly.
|
||||
disposeStaleSearchDecorations(term);
|
||||
// Disposing search decorations does not always detach the overlay nodes
|
||||
// (Esc close leaves the first two cells of the last hit). Sweep them
|
||||
// before refresh so WebGL/DOM cannot keep the yellow outline.
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
// Disposing search decorations does not always repaint cells (observed on
|
||||
// Windows after clearing or closing search). Keyword highlighting already
|
||||
// forces a refresh after dispose; do the same here so yellow match
|
||||
@@ -46,6 +354,85 @@ export const resetTerminalSearch = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pointer listeners used to tell a user-created selection apart from the
|
||||
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
|
||||
* window are rare enough that treating them as addon revival is acceptable.
|
||||
*/
|
||||
export const subscribeTerminalUserSelection = (
|
||||
term: Pick<XTerm, "element"> | null | undefined,
|
||||
mark: () => void,
|
||||
): (() => void) => {
|
||||
const el = term?.element;
|
||||
if (!el) return () => {};
|
||||
const onPointer = () => mark();
|
||||
el.addEventListener("mousedown", onPointer);
|
||||
el.addEventListener("touchstart", onPointer);
|
||||
return () => {
|
||||
el.removeEventListener("mousedown", onPointer);
|
||||
el.removeEventListener("touchstart", onPointer);
|
||||
};
|
||||
};
|
||||
|
||||
export const armSearchHighlightRevivalGuard = ({
|
||||
getSearchAddon,
|
||||
getTerm,
|
||||
subscribeUserSelection,
|
||||
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
}: {
|
||||
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
|
||||
getTerm: () => TerminalSearchGuardTarget;
|
||||
subscribeUserSelection?: (mark: () => void) => () => void;
|
||||
delayMs?: number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let userTouchedSelection = false;
|
||||
let unsubscribeUserSelection: (() => void) | null = null;
|
||||
|
||||
const markUserSelection = () => {
|
||||
userTouchedSelection = true;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
}
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
};
|
||||
|
||||
const arm = () => {
|
||||
dispose();
|
||||
userTouchedSelection = false;
|
||||
if (subscribeUserSelection) {
|
||||
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
|
||||
}
|
||||
timer = setTimeoutFn(() => {
|
||||
timer = null;
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
const term = getTerm();
|
||||
clearTerminalSearchHighlights(getSearchAddon(), term);
|
||||
// Addon findPrevious re-selects the prior active match. Clear that
|
||||
// revived selection unless the user started a new one in this window.
|
||||
if (!userTouchedSelection) {
|
||||
term?.clearSelection();
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
return { arm, dispose, markUserSelection };
|
||||
};
|
||||
|
||||
/** True when this terminal has a local query that shared search-close must clear. */
|
||||
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
|
||||
localSearchTerm !== "";
|
||||
|
||||
export const useTerminalSearch = ({
|
||||
searchAddonRef,
|
||||
termRef,
|
||||
@@ -64,6 +451,42 @@ export const useTerminalSearch = ({
|
||||
// never returns to the input. See issue #1789.
|
||||
const [searchFocusToken, setSearchFocusToken] = useState(0);
|
||||
const searchTermRef = useRef<string>("");
|
||||
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
|
||||
|
||||
// Existing sessions (and Vite HMR) never go back through createXTermRuntime.
|
||||
// Install on the live term so Esc can still find leaked decorations.
|
||||
if (termRef.current) {
|
||||
installSearchDecorationTracker(termRef.current);
|
||||
}
|
||||
|
||||
if (revivalGuardRef.current === null) {
|
||||
revivalGuardRef.current = armSearchHighlightRevivalGuard({
|
||||
getSearchAddon: () => searchAddonRef.current,
|
||||
getTerm: () => termRef.current,
|
||||
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
revivalGuardRef.current?.dispose();
|
||||
}, []);
|
||||
|
||||
const runReset = useCallback(() => {
|
||||
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
|
||||
revivalGuardRef.current?.arm();
|
||||
}, [searchAddonRef, termRef]);
|
||||
|
||||
// Search open state is shared via localStorage across terminal sessions. When
|
||||
// another session closes search, this session's bar unmounts without going
|
||||
// through handleCloseSearch — clear leftover decorations only when this
|
||||
// terminal actually searched (otherwise shared false would wipe unrelated
|
||||
// manual selections in other splits).
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) return;
|
||||
setSearchMatchCount(null);
|
||||
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
|
||||
runReset();
|
||||
}, [isSearchOpen, runReset]);
|
||||
|
||||
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
|
||||
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
|
||||
@@ -83,20 +506,29 @@ export const useTerminalSearch = ({
|
||||
setIsSearchOpen(next);
|
||||
if (!next) {
|
||||
setSearchMatchCount(null);
|
||||
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
|
||||
runReset();
|
||||
}
|
||||
}, [isSearchOpen, searchAddonRef, setIsSearchOpen, termRef]);
|
||||
}, [isSearchOpen, runReset, setIsSearchOpen]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
if (!searchAddon || !term) {
|
||||
resetTerminalSearch(searchAddon, searchTermRef, termRef.current);
|
||||
runReset();
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.noteEmptyQueryReset();
|
||||
setSearchMatchCount(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
searchTermRef.current = term;
|
||||
revivalGuardRef.current?.dispose();
|
||||
// Incremental typing (ro -> root) can leave the previous term's
|
||||
// decorations in xterm even after clearDecorations(). Drop our tracked
|
||||
// leftovers before painting the new matches.
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.markSearched();
|
||||
disposeStaleSearchDecorations(termRef.current);
|
||||
searchAddon.clearDecorations();
|
||||
|
||||
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
@@ -109,7 +541,7 @@ export const useTerminalSearch = ({
|
||||
|
||||
return found;
|
||||
},
|
||||
[searchAddonRef, termRef],
|
||||
[runReset, searchAddonRef, termRef],
|
||||
);
|
||||
|
||||
const handleFindNext = useCallback((): boolean => {
|
||||
@@ -129,9 +561,9 @@ export const useTerminalSearch = ({
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchMatchCount(null);
|
||||
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
|
||||
runReset();
|
||||
termRef.current?.focus();
|
||||
}, [searchAddonRef, setIsSearchOpen, termRef]);
|
||||
}, [runReset, setIsSearchOpen, termRef]);
|
||||
|
||||
return {
|
||||
isSearchOpen,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user