mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4291df8942 | ||
|
|
fe8344ecb2 | ||
|
|
c87f488553 | ||
|
|
a2ac9c966b | ||
|
|
b02d52516c | ||
|
|
21f5516586 | ||
|
|
cdcb628887 | ||
|
|
c0a8e1a789 | ||
|
|
38912ac9bd | ||
|
|
338722cd89 | ||
|
|
f7ca60bab4 | ||
|
|
aa5ee1e4c0 | ||
|
|
fd29317a99 | ||
|
|
1f1af89023 | ||
|
|
a36980e9d2 | ||
|
|
c92eaca6ec | ||
|
|
387bb7a751 | ||
|
|
3ae1f09a4d | ||
|
|
0768c8e0a8 | ||
|
|
c6233b325e | ||
|
|
91dfebe809 | ||
|
|
edaf05a29d | ||
|
|
ea7954572c | ||
|
|
369016b5b5 | ||
|
|
83c3adb6fa | ||
|
|
159b98be80 | ||
|
|
b686686efb | ||
|
|
b2b79443af | ||
|
|
015e02424d | ||
|
|
4bfb26b1eb | ||
|
|
b8432cc907 | ||
|
|
8fe69f6101 | ||
|
|
127f32822f | ||
|
|
927feb9c79 | ||
|
|
b8b180440e | ||
|
|
af37e01b8c | ||
|
|
9b7c16263c | ||
|
|
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 歷史',
|
||||
|
||||
@@ -18,8 +18,103 @@ import {
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
draftsByScopeEqualIgnoringComposerText,
|
||||
draftsByScopeEqualIgnoringAllComposerText,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("draftsByScopeEqualIgnoringComposerText ignores typing in the active scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": { ...base, text: "" } };
|
||||
const next = { "terminal:1": { ...base, text: "你好", updatedAt: base.updatedAt + 1 } };
|
||||
assert.equal(draftsByScopeEqualIgnoringComposerText(prev, next, "terminal:1"), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": { ...base, text: "你好", attachments: [{ id: "a" } as never] } },
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{ "terminal:1": prev["terminal:1"], "workspace:2": base },
|
||||
"terminal:1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText(
|
||||
prev,
|
||||
{
|
||||
"terminal:1": prev["terminal:1"],
|
||||
"workspace:2": { ...base, attachments: [{ id: "a" } as never] },
|
||||
},
|
||||
"terminal:1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("first composer draft is treated as text-only identity churn", () => {
|
||||
const empty = createEmptyDraft("catty");
|
||||
const created = { ...empty, text: "你" };
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": empty }), false);
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": created }), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringComposerText({}, { "terminal:1": created }, "terminal:1"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{ "terminal:1": empty },
|
||||
{ "terminal:1": created },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{ "terminal:1": created },
|
||||
{ "terminal:1": empty },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(
|
||||
{},
|
||||
{ "terminal:1": { ...created, attachments: [{ id: "a" } as never] } },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draftsByScopeEqualIgnoringAllComposerText ignores typing in every scope", () => {
|
||||
const base = createEmptyDraft("catty");
|
||||
const prev = {
|
||||
"terminal:1": { ...base, text: "a" },
|
||||
"terminal:2": { ...base, text: "b", attachments: [] },
|
||||
};
|
||||
const next = {
|
||||
"terminal:1": { ...prev["terminal:1"], text: "aa", updatedAt: base.updatedAt + 1 },
|
||||
"terminal:2": { ...prev["terminal:2"], text: "bb", updatedAt: base.updatedAt + 2 },
|
||||
};
|
||||
assert.equal(draftsByScopeEqualIgnoringAllComposerText(prev, next), true);
|
||||
assert.equal(
|
||||
draftsByScopeEqualIgnoringAllComposerText(prev, {
|
||||
...next,
|
||||
"terminal:2": { ...next["terminal:2"], attachments: [{ id: "a" } as never] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("updateDraftForScope keeps the original map when the updater is a no-op", () => {
|
||||
const draft = createEmptyDraft("catty");
|
||||
const prev = { "terminal:1": draft };
|
||||
const next = updateDraftForScope(prev, "terminal:1", "catty", (current) => current);
|
||||
assert.equal(next, prev);
|
||||
});
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
|
||||
@@ -11,6 +11,65 @@ type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
function isComposerOnlyDraft(draft: AIDraft | undefined): boolean {
|
||||
return Boolean(
|
||||
draft
|
||||
&& draft.attachments.length === 0
|
||||
&& draft.selectedUserSkillSlugs.length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* First keystroke creates a missing → composer-only draft. That is still just
|
||||
* typing. Clearing a draft (`right` missing or emptied) is a real lifecycle
|
||||
* change so New Chat / send can reset the uncontrolled composer.
|
||||
*/
|
||||
function draftsEquivalentIgnoringComposerText(
|
||||
left: AIDraft | undefined,
|
||||
right: AIDraft | undefined,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left) return Boolean(isComposerOnlyDraft(right) && right.text.trim().length > 0);
|
||||
if (!right) return false;
|
||||
if (left.agentId !== right.agentId) return false;
|
||||
if (left.attachments !== right.attachments) return false;
|
||||
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
|
||||
const leftEmpty = left.text.trim().length === 0;
|
||||
const rightEmpty = right.text.trim().length === 0;
|
||||
if (leftEmpty !== rightEmpty) return leftEmpty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when every scope is unchanged except composer text/updatedAt. */
|
||||
export function draftsByScopeEqualIgnoringAllComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
if (!draftsEquivalentIgnoringComposerText(prev[key], next[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Typing only changes text/updatedAt. Sibling empty-draft creates stay ignored. */
|
||||
export function draftsByScopeEqualIgnoringComposerText(
|
||||
prev: DraftsByScope,
|
||||
next: DraftsByScope,
|
||||
scopeKey: string,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
for (const key of keys) {
|
||||
const left = prev[key];
|
||||
const right = next[key];
|
||||
if (key !== scopeKey && !left && isComposerOnlyDraft(right)) continue;
|
||||
if (!draftsEquivalentIgnoringComposerText(left, right)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
@@ -142,6 +201,9 @@ export function updateDraftForScope(
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
if (nextDraft === currentDraft && draftsByScope[scopeKey] === currentDraft) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createEmptyDraft } from './aiDraftState.ts';
|
||||
import { aiSessionsStore } from './aiSessionsStore.ts';
|
||||
|
||||
test('AI sessions store does not notify listeners when only composer text changes', () => {
|
||||
const previous = aiSessionsStore.getSnapshot();
|
||||
const draft = createEmptyDraft('catty');
|
||||
const base = {
|
||||
sessions: Object.freeze([]),
|
||||
activeSessionIdMap: Object.freeze({}),
|
||||
draftsByScope: { 'terminal:1': draft },
|
||||
panelViewByScope: {},
|
||||
};
|
||||
|
||||
try {
|
||||
aiSessionsStore.setSnapshot(base);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = aiSessionsStore.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', updatedAt: draft.updatedAt + 1 },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 0);
|
||||
assert.equal(aiSessionsStore.getSnapshot().draftsByScope['terminal:1']?.text, 'hello');
|
||||
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {},
|
||||
});
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: '你好' },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 0);
|
||||
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: '' },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 1);
|
||||
|
||||
notified = 0;
|
||||
aiSessionsStore.setSnapshot({
|
||||
...base,
|
||||
draftsByScope: {
|
||||
'terminal:1': { ...draft, text: 'hello', attachments: [{ id: 'a' } as never] },
|
||||
},
|
||||
});
|
||||
assert.equal(notified, 1);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
aiSessionsStore.setSnapshot(previous);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { draftsByScopeEqualIgnoringAllComposerText } from './aiDraftState';
|
||||
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -50,7 +51,16 @@ class AISessionsStore {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const textOnlyDraftChange =
|
||||
this.snapshot.sessions === next.sessions
|
||||
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
|
||||
&& this.snapshot.panelViewByScope === next.panelViewByScope
|
||||
&& draftsByScopeEqualIgnoringAllComposerText(
|
||||
this.snapshot.draftsByScope,
|
||||
next.draftsByScope,
|
||||
);
|
||||
this.snapshot = next;
|
||||
if (textOnlyDraftChange) return;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
|
||||
@@ -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,66 @@
|
||||
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 treats Electron-wrapped SFTP absence as missing", () => {
|
||||
// ipcRenderer.invoke strips custom `code` and wraps the ssh2 message.
|
||||
// New-file uploads lstat the destination first; this is the toast users see.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isMissingStatError(new Error("No such file")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file or directory")), true);
|
||||
assert.equal(isMissingStatError(new Error("No such file: /tmp/tool.sh")), true);
|
||||
assert.equal(
|
||||
isMissingStatError(new Error("ENOENT: no such file or directory, lstat '/tmp/tool.sh'")),
|
||||
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);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: Permission denied"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
// Path names are user-controlled; do not treat a substring hit as absence.
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMissingStatError(
|
||||
new Error(
|
||||
"Error invoking remote method 'netcatty:sftp:lstat': Error: EACCES: permission denied, lstat '/private/enoent/report.txt'",
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -17,3 +17,5 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -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,374 @@ 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("Electron-wrapped lstat absence still means absent destination", 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 () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file");
|
||||
},
|
||||
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 }),
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
activateDraftView,
|
||||
clearScopeDraftState,
|
||||
draftsByScopeEqualIgnoringAllComposerText,
|
||||
ensureDraftForScopeState,
|
||||
pruneStaleSessionPanelViews,
|
||||
setDraftView,
|
||||
@@ -786,18 +787,21 @@ export function useAIState() {
|
||||
|
||||
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let textOnly = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
||||
if (next === prev) return prev;
|
||||
nextDraftsByScope = next;
|
||||
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextDraftsByScope) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
if (textOnly) return;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
@@ -806,52 +810,69 @@ export function useAIState() {
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let textOnly = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = updateDraftForScope(
|
||||
prev,
|
||||
scopeKey,
|
||||
fallbackAgentId,
|
||||
(draft) => {
|
||||
const updated = updater(draft);
|
||||
if (updated === draft) return draft;
|
||||
return {
|
||||
...updater(draft),
|
||||
...updated,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
);
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
if (next === prev) return prev;
|
||||
nextDraftsByScope = next;
|
||||
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextDraftsByScope) return;
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
if (textOnly) return;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const updateDraftIfPresent = useCallback((
|
||||
scopeKey: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let updated = false;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let textOnly = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const currentDraft = prev[scopeKey];
|
||||
if (!currentDraft) return prev;
|
||||
|
||||
const nextDraft = {
|
||||
...updater(currentDraft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const updated = updater(currentDraft);
|
||||
const nextDraft = updated === currentDraft
|
||||
? currentDraft
|
||||
: {
|
||||
...updated,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (nextDraft === currentDraft) return prev;
|
||||
const next = {
|
||||
...prev,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
updated = true;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
nextDraftsByScope = next;
|
||||
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
if (!nextDraftsByScope) return;
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
if (textOnly) return;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
|
||||
@@ -22,10 +22,16 @@ let agentDiscoveryWriteGeneration = 0;
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
options?: { enabled?: boolean },
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
schedule?: (task: () => void) => () => void;
|
||||
},
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const schedule = options?.schedule;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>(
|
||||
() => agentDiscoveryCache?.agents ?? [],
|
||||
);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
const discoverSeqRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -116,8 +122,16 @@ export function useAgentDiscovery(
|
||||
if (!cancelled) void discover();
|
||||
};
|
||||
|
||||
if (schedule) {
|
||||
const cancel = schedule(runDiscover);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
|
||||
const idleId = requestIdleCallback(runDiscover);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
@@ -129,7 +143,7 @@ export function useAgentDiscovery(
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [discover, enabled]);
|
||||
}, [discover, enabled, schedule]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
|
||||
@@ -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.
@@ -89,6 +89,10 @@ interface AIChatPanelContentProps {
|
||||
onOpenVaultNote?: (noteId: string) => void;
|
||||
onOpenVaultHost?: (hostId: string) => void;
|
||||
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
||||
/** Hidden retained panels keep the composer warm without the message tree. */
|
||||
parked?: boolean;
|
||||
/** Disable header transitions while send preflight is in flight. */
|
||||
sending?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
@@ -152,7 +156,37 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onOpenVaultNote,
|
||||
onOpenVaultHost,
|
||||
onOpenVaultSection,
|
||||
parked = false,
|
||||
sending = false,
|
||||
}) => {
|
||||
const mentionHosts = React.useMemo(
|
||||
() => terminalSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
hostname: session.hostname,
|
||||
label: session.label,
|
||||
connected: session.connected,
|
||||
})),
|
||||
[terminalSessions],
|
||||
);
|
||||
const providerSwitcher = React.useMemo(
|
||||
() => (
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
[
|
||||
cattyConfiguredProviders,
|
||||
currentAgentId,
|
||||
effectiveActiveModelId,
|
||||
effectiveActiveProvider?.id,
|
||||
handleAgentProviderModelSelect,
|
||||
],
|
||||
);
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
|
||||
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
|
||||
@@ -174,6 +208,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
parked={parked}
|
||||
disabled={sending}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
@@ -191,6 +227,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
disabled={sending}
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={12} />
|
||||
@@ -204,6 +241,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
disabled={sending}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={13} />
|
||||
@@ -217,12 +255,12 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory && !hideHistory ? (
|
||||
{!parked && showHistory && !hideHistory ? (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onSelect={sending ? () => undefined : handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
@@ -230,7 +268,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
{!hideMessages && (
|
||||
{!parked && !hideMessages && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
@@ -247,7 +285,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
)}
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
||||
{!parked && messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
@@ -262,8 +300,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
disabled={sending}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
@@ -291,6 +330,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
</div>
|
||||
) : null}
|
||||
<ChatInput
|
||||
parked={parked}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
@@ -309,20 +349,11 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
providerSwitcher={providerSwitcher}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
hosts={mentionHosts}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
@@ -108,6 +109,51 @@ const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePan
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('send preflight aborts after the panel unmounts or the scope changes', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /sendEpochRef/);
|
||||
assert.match(source, /if \(isSendStale\(\)\) return;/);
|
||||
});
|
||||
|
||||
test('send preflight locks header agent and new-chat controls', () => {
|
||||
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
assert.match(panel, /sending=\{isSending\}/);
|
||||
assert.match(content, /disabled=\{sending\}/);
|
||||
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
||||
});
|
||||
|
||||
test('first send awaits provider sync instead of idle-deferring it', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /await sendBridge\.aiSyncProviders\(providers\)/);
|
||||
assert.match(source, /await sendBridge\.aiSyncWebSearch\(/);
|
||||
assert.doesNotMatch(source, /scheduleWhenAiComposerIdle\(\(\) => \{\s*void bridge\.aiSyncProviders/);
|
||||
});
|
||||
|
||||
test('send and new chat discard pending composer text', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /discardPendingComposerText/);
|
||||
assert.match(source, /if \(!options\?\.keepPendingText\) discardPendingComposerText\(\)/);
|
||||
});
|
||||
|
||||
test('text-only draft writes do not emit AI state changes', () => {
|
||||
const source = readFileSync(new URL('../application/state/useAIState.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /draftsByScopeEqualIgnoringAllComposerText/);
|
||||
assert.match(source, /if \(textOnly\) return;/);
|
||||
});
|
||||
|
||||
test('first composer keystroke does not create a store draft synchronously', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const setter = source.slice(
|
||||
source.indexOf('const setInputValue = useCallback'),
|
||||
source.indexOf('const addFiles = useCallback'),
|
||||
);
|
||||
assert.match(setter, /pendingComposerTextRef\.current = value/);
|
||||
assert.doesNotMatch(setter, /enterScopeDraftMode/);
|
||||
assert.doesNotMatch(setter, /setTimeout/);
|
||||
});
|
||||
|
||||
test('hidden empty AI side panel can release its subtree', () => {
|
||||
const props = baseProps();
|
||||
|
||||
@@ -184,7 +230,7 @@ test('AI side panel re-renders when retained content becomes visible again', ()
|
||||
), false);
|
||||
});
|
||||
|
||||
test('hidden retained AI side panel skips chat input and message content', () => {
|
||||
test('hidden retained AI side panel keeps the composer and skips message content', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
@@ -212,10 +258,11 @@ test('hidden retained AI side panel skips chat input and message content', () =>
|
||||
);
|
||||
|
||||
assert.match(markup, /data-section="ai-chat-panel-retained"/);
|
||||
assert.doesNotMatch(markup, /textarea/);
|
||||
assert.match(markup, /inert/);
|
||||
assert.match(markup, /aria-hidden/);
|
||||
assert.match(markup, /textarea/);
|
||||
assert.doesNotMatch(markup, /hidden-user-message/);
|
||||
assert.doesNotMatch(markup, /hidden-assistant-message/);
|
||||
assert.doesNotMatch(markup, /draft still retained/);
|
||||
});
|
||||
|
||||
test('AI side panel re-renders when command timeout changes', () => {
|
||||
@@ -253,6 +300,25 @@ test('AI side panel skips re-render when only a sibling scope session object cha
|
||||
);
|
||||
});
|
||||
|
||||
test('AI side panel skips re-render when only the composer draft text changes', () => {
|
||||
const empty = draft({ text: '' });
|
||||
const prev = baseProps({
|
||||
isVisible: true,
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': empty,
|
||||
},
|
||||
});
|
||||
const next = {
|
||||
...prev,
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': { ...empty, text: '你好', updatedAt: 2 },
|
||||
},
|
||||
};
|
||||
assert.equal(aiChatSidePanelPropsAreEqual(prev, next), true);
|
||||
});
|
||||
|
||||
test('workspace AI panel memo follows visible inherited session, not hidden terminal maps', () => {
|
||||
const visibleMemberChat = session({
|
||||
id: 'chat-visible',
|
||||
|
||||
+211
-93
@@ -38,7 +38,7 @@ import {
|
||||
endSendForKey,
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
@@ -79,6 +79,7 @@ import {
|
||||
getAIPanelProfilerProps,
|
||||
profileAIPanelCalculation,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
import { scheduleWhenAiComposerIdle, warmAiMarkdownRenderer } from './ai/aiMarkdownWarmup';
|
||||
|
||||
type UserSkillsStatusResult = { ok: boolean; skills?: Array<{
|
||||
id: string;
|
||||
@@ -198,15 +199,9 @@ export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): b
|
||||
return isAIChatSessionStreaming(sessionId);
|
||||
}
|
||||
|
||||
function shouldDelayAIChatSidePanelActivation(props: AIChatSidePanelProps): boolean {
|
||||
if (!(props.isVisible ?? true)) return false;
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
if (props.draftsByScope[scopeKey] || props.panelViewByScope[scopeKey]?.mode === 'draft') {
|
||||
return false;
|
||||
}
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
if (isAIChatSessionStreaming(sessionId)) return false;
|
||||
return !hasAIChatSidePanelRetainedContent(props);
|
||||
function shouldDelayAIChatSidePanelActivation(_props: AIChatSidePanelProps): boolean {
|
||||
// Empty-panel activation delay used to land in the expand → first-type window.
|
||||
return false;
|
||||
}
|
||||
|
||||
function schedulePanelActivation(callback: () => void): () => void {
|
||||
@@ -303,6 +298,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
@@ -377,6 +373,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const pendingComposerTextRef = useRef<string | null>(null);
|
||||
const visibleHistorySessionIds = useMemo(
|
||||
() => new Set(historySessions.map((session) => session.id)),
|
||||
[historySessions],
|
||||
@@ -402,12 +399,41 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const isSteering = activeSessionId != null && steeringSessionId === activeSessionId;
|
||||
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
|
||||
const observedContextUsage = useAgentContextUsage(activeSessionId);
|
||||
const inputValue = currentDraft?.text ?? '';
|
||||
const inputValue = pendingComposerTextRef.current ?? currentDraft?.text ?? '';
|
||||
const files = currentDraft?.attachments ?? [];
|
||||
const panelViewRef = useRef(normalizedPanelView);
|
||||
panelViewRef.current = normalizedPanelView;
|
||||
const permissionModeRef = useRef(globalPermissionMode);
|
||||
permissionModeRef.current = globalPermissionMode;
|
||||
const sendEpochRef = useRef(0);
|
||||
useEffect(() => () => {
|
||||
sendEpochRef.current += 1;
|
||||
}, [scopeKey]);
|
||||
const currentDraftRef = useRef(currentDraft);
|
||||
currentDraftRef.current = currentDraft;
|
||||
if (pendingComposerTextRef.current != null) {
|
||||
const pending = pendingComposerTextRef.current;
|
||||
if (currentDraft && currentDraft.text === pending) {
|
||||
pendingComposerTextRef.current = null;
|
||||
currentDraftRef.current = currentDraft;
|
||||
} else if (currentDraftRef.current?.text === pending) {
|
||||
if (currentDraft && currentDraftRef.current) {
|
||||
currentDraftRef.current.attachments = currentDraft.attachments;
|
||||
currentDraftRef.current.selectedUserSkillSlugs = currentDraft.selectedUserSkillSlugs;
|
||||
currentDraftRef.current.agentId = currentDraft.agentId;
|
||||
}
|
||||
} else {
|
||||
const base = currentDraft ?? currentDraftRef.current ?? {
|
||||
text: '',
|
||||
agentId: currentAgentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
currentDraftRef.current = { ...base, text: pending };
|
||||
}
|
||||
} else {
|
||||
currentDraftRef.current = currentDraft;
|
||||
}
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
@@ -439,13 +465,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
return scheduleWhenAiComposerIdle(() => {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, { initialDelayMs: 250 });
|
||||
}, [isVisible, terminalSessions, activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -517,9 +539,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
showSessionView(scopeKey, sessionId);
|
||||
}, [scopeKey, showSessionView]);
|
||||
|
||||
const clearScopeDraft = useCallback(() => {
|
||||
const discardPendingComposerText = useCallback(() => {
|
||||
pendingComposerTextRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearScopeDraft = useCallback((options?: { keepPendingText?: boolean }) => {
|
||||
if (!options?.keepPendingText) discardPendingComposerText();
|
||||
clearDraftForScope(scopeKey);
|
||||
}, [clearDraftForScope, scopeKey]);
|
||||
}, [clearDraftForScope, discardPendingComposerText, scopeKey]);
|
||||
|
||||
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
|
||||
applyDraftEntrySelection({
|
||||
@@ -529,14 +556,29 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
}, [ensureScopeDraft, showScopeDraftView]);
|
||||
|
||||
const setInputValue = useCallback((value: string) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => ({
|
||||
...draft,
|
||||
text: value,
|
||||
}));
|
||||
const flushDraftText = useCallback(() => {
|
||||
const pending = pendingComposerTextRef.current;
|
||||
if (pending == null) return;
|
||||
if (panelViewRef.current.mode !== 'draft') {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
}
|
||||
updateScopeDraft(currentAgentId, (current) => (
|
||||
current.text === pending ? current : { ...current, text: pending }
|
||||
));
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const setInputValue = useCallback((value: string) => {
|
||||
const base = currentDraftRef.current ?? {
|
||||
text: '',
|
||||
agentId: currentAgentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
pendingComposerTextRef.current = value;
|
||||
currentDraftRef.current = { ...base, text: value, updatedAt: Date.now() };
|
||||
}, [currentAgentId]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
|
||||
@@ -546,6 +588,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
}, [removeDraftFile, scopeKey, currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) return undefined;
|
||||
flushDraftText();
|
||||
return undefined;
|
||||
}, [flushDraftText, isVisible]);
|
||||
|
||||
useEffect(() => () => {
|
||||
flushDraftText();
|
||||
}, [flushDraftText, scopeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
flushDraftText();
|
||||
}, [activeSessionId, flushDraftText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
@@ -586,16 +642,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
void loadUserSkillsStatus(bridge)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === undefined) return;
|
||||
applyUserSkillsStatus(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void loadUserSkillsStatus(bridge)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result === undefined) return;
|
||||
applyUserSkillsStatus(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft, userSkillsStatusVersion]);
|
||||
|
||||
@@ -609,17 +668,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
if (!bridge?.aiSyncProviders || providers.length === 0) return;
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}, [isVisible, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
if (!bridge?.aiSyncWebSearch) return;
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
const {
|
||||
@@ -627,7 +684,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents, {
|
||||
enabled: isVisible,
|
||||
schedule: scheduleWhenAiComposerIdle,
|
||||
});
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
@@ -745,7 +805,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
@@ -868,15 +930,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
// Respect renderer TTL / in-flight coalescing for all SDK agents including
|
||||
// OpenCode. Forced refresh used to re-spawn opencode on every effect re-run
|
||||
// even when the user never selected OpenCode (#2184). Manual refresh still
|
||||
// passes force via the model selector path.
|
||||
// passes force via the model selector path. Defer the network refresh so
|
||||
// expand → first type does not wait on CLI model listing.
|
||||
let cancelled = false;
|
||||
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
|
||||
if (cancelled || !catalog) return;
|
||||
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [
|
||||
isVisible,
|
||||
@@ -939,8 +1005,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
() => canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents],
|
||||
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending],
|
||||
);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
@@ -1052,13 +1118,65 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
flushDraftText();
|
||||
const submittedText = draft?.text ?? '';
|
||||
const keepPendingAfterSend = () => {
|
||||
const pending = pendingComposerTextRef.current;
|
||||
return pending != null && pending !== submittedText;
|
||||
};
|
||||
const sendGateKey = currentSessionView?.id ?? `draft:${scopeKey}`;
|
||||
if (!tryBeginSendForKey(sendGateKey)) {
|
||||
return;
|
||||
}
|
||||
let sessionSendGateKey: string | null = null;
|
||||
const sendEpoch = sendEpochRef.current;
|
||||
const isSendStale = () => sendEpochRef.current !== sendEpoch;
|
||||
setIsSending(true);
|
||||
|
||||
try {
|
||||
const sendBridge = getNetcattyBridge();
|
||||
if (sendBridge?.aiSyncProviders && providers.length > 0) {
|
||||
await sendBridge.aiSyncProviders(providers);
|
||||
}
|
||||
if (sendBridge?.aiSyncWebSearch) {
|
||||
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
let sendSelectedAgentModel = selectedAgentModel;
|
||||
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (runtimeTarget) {
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
if (catalog) {
|
||||
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
|
||||
const runtimePresets = normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId);
|
||||
const storedModelId = agentModelMapRef.current[sendAgentId];
|
||||
if (
|
||||
catalog.currentModelId
|
||||
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
||||
) {
|
||||
sendSelectedAgentModel = catalog.currentModelId;
|
||||
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
|
||||
sendSelectedAgentModel = storedModelId ?? sendSelectedAgentModel;
|
||||
} else if (runtimePresets[0]) {
|
||||
sendSelectedAgentModel = runtimePresets[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsSending(false);
|
||||
const sendPermissionMode = permissionModeRef.current;
|
||||
if (isSendStale()) return;
|
||||
if (currentAgentId !== sendAgentId) return;
|
||||
const liveView = panelViewRef.current;
|
||||
if (liveView.mode !== currentPanelView.mode) return;
|
||||
if (
|
||||
currentPanelView.mode === 'session'
|
||||
&& liveView.mode === 'session'
|
||||
&& liveView.sessionId !== currentPanelView.sessionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void warmAiMarkdownRenderer();
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
if (isDraftMode) {
|
||||
@@ -1066,7 +1184,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
sessionSendGateKey = sessionId;
|
||||
@@ -1096,7 +1214,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1110,7 +1228,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
@@ -1121,7 +1239,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
@@ -1130,7 +1248,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
? (sendSelectedAgentModel || agentConfig?.name || 'external')
|
||||
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
|
||||
});
|
||||
@@ -1154,10 +1272,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
selectedAgentModel: sendSelectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
permissionMode: globalPermissionMode,
|
||||
permissionMode: sendPermissionMode,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
@@ -1178,7 +1296,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
globalPermissionMode: sendPermissionMode,
|
||||
commandBlocklist,
|
||||
commandTimeout,
|
||||
terminalSessions,
|
||||
@@ -1190,6 +1308,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
endSendForKey(sendGateKey);
|
||||
if (sessionSendGateKey && sessionSendGateKey !== sendGateKey) {
|
||||
endSendForKey(sessionSendGateKey);
|
||||
@@ -1202,9 +1321,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setStreamingForScope,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, commandBlocklist, commandTimeout, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog,
|
||||
]);
|
||||
|
||||
const handleCompact = useCallback(async () => {
|
||||
@@ -1462,45 +1583,19 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
// Warm the Streamdown/markdown chunk while the panel is visible but idle, so
|
||||
// the first completed assistant reply does not pay the full parse cost mid-interaction.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
const loadMarkdown = () => {
|
||||
if (cancelled) return;
|
||||
void import('./ai-elements/messageResponse');
|
||||
};
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(loadMarkdown, { timeout: 2500 });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
const timeoutId = window.setTimeout(loadMarkdown, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
// Keep streaming/session hooks alive for retained hidden panels, but do not
|
||||
// mount ChatMessageList / Streamdown / ChatInput while CSS-hidden — those trees
|
||||
// are the main tab-switch and cross-tab re-render cost.
|
||||
if (!isVisible) {
|
||||
return (
|
||||
<div
|
||||
className="h-full min-h-0 bg-background"
|
||||
data-section="ai-chat-panel-retained"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Hidden retained panels keep the composer mounted (parent is `hidden` +
|
||||
// inert) so reopen does not remount ChatInput. Skip the message list.
|
||||
return (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
|
||||
<div
|
||||
className="h-full min-h-0"
|
||||
data-section={isVisible ? undefined : 'ai-chat-panel-retained'}
|
||||
aria-hidden={isVisible ? undefined : true}
|
||||
inert={isVisible ? undefined : true}
|
||||
>
|
||||
<AIChatPanelContent
|
||||
parked={!isVisible}
|
||||
sending={isSending}
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -1535,7 +1630,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
canSteer={canSteerCurrentTurn}
|
||||
isSteering={isSteering}
|
||||
steerWarning={steerWarning}
|
||||
lockTurnConfiguration={Boolean(isStreaming && isCodexAppServer)}
|
||||
lockTurnConfiguration={Boolean(isSending || (isStreaming && isCodexAppServer))}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
@@ -1566,6 +1661,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
onOpenVaultSnippet={onOpenVaultSnippet}
|
||||
onOpenVaultSection={onOpenVaultSection}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
);
|
||||
};
|
||||
@@ -1641,6 +1737,22 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
|
||||
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
|
||||
|
||||
const scopeKey = `${prev.scopeType}:${prev.scopeTargetId ?? ''}`;
|
||||
if (
|
||||
prev.sessions === next.sessions
|
||||
&& draftsByScopeEqualIgnoringComposerText(prev.draftsByScope, next.draftsByScope, scopeKey)
|
||||
) {
|
||||
let restEqual = true;
|
||||
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
||||
if (key === 'sessions' || key === 'draftsByScope') continue;
|
||||
if (prev[key] !== next[key]) {
|
||||
restEqual = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (restEqual) return true;
|
||||
}
|
||||
|
||||
// Sibling stream thrash: full sessions array identity always changes. Only
|
||||
// exact-scope session object refs matter for this panel's active chat —
|
||||
// plus the currently selected session, which may be a host-matched history
|
||||
@@ -1706,6 +1818,12 @@ export function aiChatSidePanelPropsAreEqual(
|
||||
|
||||
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
||||
if (key === 'sessions') continue;
|
||||
if (key === 'draftsByScope') {
|
||||
if (!draftsByScopeEqualIgnoringComposerText(prev.draftsByScope, next.draftsByScope, scopeKey)) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (prev[key] !== next[key]) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -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,12 +1,24 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
enqueueChatMarkdownHydrate,
|
||||
isAiMarkdownRendererReady,
|
||||
scheduleWhenAiComposerIdle,
|
||||
subscribeAiMarkdownRendererReady,
|
||||
warmAiMarkdownRenderer,
|
||||
} from '../ai/aiMarkdownWarmup';
|
||||
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
|
||||
|
||||
type LazyMessageResponseProps = {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
isAnimating?: boolean;
|
||||
/**
|
||||
* Keep plaintext until Streamdown is already warmed. Chat history uses this
|
||||
* so expanding the panel cannot start the ~350KB parse during first typing.
|
||||
*/
|
||||
deferUntilWarm?: boolean;
|
||||
};
|
||||
|
||||
const MessageResponse = lazy(() =>
|
||||
@@ -20,11 +32,34 @@ const PlainTextFallback = ({ children, className }: LazyMessageResponseProps) =>
|
||||
);
|
||||
|
||||
export function LazyMessageResponse(props: LazyMessageResponseProps) {
|
||||
const resetKey = typeof props.children === 'string' ? props.children : undefined;
|
||||
const { deferUntilWarm = false, ...rendererProps } = props;
|
||||
const [ready, setReady] = useState(() => !deferUntilWarm && isAiMarkdownRendererReady());
|
||||
const resetKey = typeof rendererProps.children === 'string' ? rendererProps.children : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) return undefined;
|
||||
if (!deferUntilWarm) {
|
||||
const unsubscribe = subscribeAiMarkdownRendererReady(() => setReady(true));
|
||||
if (isAiMarkdownRendererReady()) return unsubscribe;
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void warmAiMarkdownRenderer();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
cancelIdle();
|
||||
};
|
||||
}
|
||||
return enqueueChatMarkdownHydrate(() => setReady(true));
|
||||
}, [deferUntilWarm, ready]);
|
||||
|
||||
if (deferUntilWarm && !ready) {
|
||||
return <PlainTextFallback {...rendererProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyLoadBoundary fallback={<PlainTextFallback {...props} />} resetKey={resetKey}>
|
||||
<Suspense fallback={<PlainTextFallback {...props} />}>
|
||||
<MessageResponse {...props} />
|
||||
<LazyLoadBoundary fallback={<PlainTextFallback {...rendererProps} />} resetKey={resetKey}>
|
||||
<Suspense fallback={<PlainTextFallback {...rendererProps} />}>
|
||||
<MessageResponse {...rendererProps} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { hasMarkdownCodeFence } from './hasMarkdownCodeFence';
|
||||
|
||||
test('detects common markdown fences', () => {
|
||||
assert.equal(hasMarkdownCodeFence('hello'), false);
|
||||
assert.equal(hasMarkdownCodeFence('use `code` inline'), false);
|
||||
assert.equal(hasMarkdownCodeFence('```ts\nconst a = 1\n```'), true);
|
||||
assert.equal(hasMarkdownCodeFence('prefix\n```\nplain\n```'), true);
|
||||
assert.equal(hasMarkdownCodeFence('~~~\nbash\n~~~'), true);
|
||||
assert.equal(hasMarkdownCodeFence(' ```json\n{}\n```'), true);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
const FENCE_RE = /(?:^|\n)\s{0,3}(`{3,}|~{3,})/;
|
||||
|
||||
/** True when markdown contains a fenced code block that may want Shiki. */
|
||||
export function hasMarkdownCodeFence(text: string): boolean {
|
||||
return FENCE_RE.test(text);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('messageResponse does not statically import Shiki', () => {
|
||||
const source = readFileSync(new URL('./messageResponse.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /from ['"]@streamdown\/code['"]/);
|
||||
assert.match(source, /from ['"]@streamdown\/cjk['"]/);
|
||||
assert.match(source, /hasMarkdownCodeFence/);
|
||||
assert.match(source, /warmAiCodeHighlighter/);
|
||||
assert.match(source, /scheduleWhenAiComposerIdle/);
|
||||
});
|
||||
|
||||
test('Shiki lives in an isolated plugin module', () => {
|
||||
const plugin = readFileSync(new URL('./streamdownCodePlugin.ts', import.meta.url), 'utf8');
|
||||
const warmup = readFileSync(new URL('./streamdownCodeWarmup.ts', import.meta.url), 'utf8');
|
||||
assert.match(plugin, /from ['"]@streamdown\/code['"]/);
|
||||
assert.match(warmup, /import\('\.\/streamdownCodePlugin'\)/);
|
||||
assert.doesNotMatch(warmup, /from ['"]@streamdown\/code['"]/);
|
||||
});
|
||||
@@ -1,43 +1,73 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cjk } from '@streamdown/cjk';
|
||||
import { code } from '@streamdown/code';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
|
||||
|
||||
const safeCode = createSafeCodeHighlighter(code);
|
||||
const streamdownPlugins = { cjk, code: safeCode };
|
||||
import { scheduleWhenAiComposerIdle } from '../ai/aiMarkdownWarmup';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { hasMarkdownCodeFence } from './hasMarkdownCodeFence';
|
||||
import {
|
||||
getCachedStreamdownCodePlugin,
|
||||
warmAiCodeHighlighter,
|
||||
} from './streamdownCodeWarmup';
|
||||
|
||||
const STREAMDOWN_CLASS = [
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
].join(' ');
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
/** Heavy markdown renderer — load via LazyMessageResponse, not the chat list critical path. */
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
function MessageResponseView({ className, children, ...props }: MessageResponseProps) {
|
||||
const [codePlugin, setCodePlugin] = useState(getCachedStreamdownCodePlugin);
|
||||
const source = typeof children === 'string' ? children : '';
|
||||
const wantsCode = hasMarkdownCodeFence(source);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsCode || codePlugin) return undefined;
|
||||
let cancelled = false;
|
||||
const cancelIdle = scheduleWhenAiComposerIdle(() => {
|
||||
void warmAiCodeHighlighter().then((plugin) => {
|
||||
if (!cancelled) setCodePlugin(plugin);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdle();
|
||||
};
|
||||
}, [codePlugin, wantsCode]);
|
||||
|
||||
const plugins = useMemo(
|
||||
() => (codePlugin ? { cjk, code: codePlugin } : { cjk }),
|
||||
[codePlugin],
|
||||
);
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
// Style the rendered markdown
|
||||
// Code: base styles (code-block overrides are in index.css)
|
||||
'[&_code]:text-[12px] [&_code]:font-mono',
|
||||
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
|
||||
'[&_p]:my-1.5',
|
||||
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
|
||||
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
|
||||
'[&_li]:my-0.5',
|
||||
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
|
||||
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
|
||||
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
|
||||
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
|
||||
'[&_a]:text-primary [&_a]:underline',
|
||||
'[&_hr]:border-border/30 [&_hr]:my-3',
|
||||
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
className={cn(STREAMDOWN_CLASS, className)}
|
||||
plugins={plugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** Streamdown + CJK only. Shiki loads later when a fence exists and the composer is idle. */
|
||||
export const MessageResponse = memo(
|
||||
MessageResponseView,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { code } from '@streamdown/code';
|
||||
|
||||
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
|
||||
|
||||
/** Isolated Shiki entry — import this file only through warmAiCodeHighlighter. */
|
||||
export const streamdownCodePlugin = createSafeCodeHighlighter(code);
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CodeHighlighterPlugin } from 'streamdown';
|
||||
|
||||
let cachedCodePlugin: CodeHighlighterPlugin | null = null;
|
||||
let codePluginPromise: Promise<CodeHighlighterPlugin> | null = null;
|
||||
|
||||
export function getCachedStreamdownCodePlugin(): CodeHighlighterPlugin | null {
|
||||
return cachedCodePlugin;
|
||||
}
|
||||
|
||||
export function isAiCodeHighlighterReady(): boolean {
|
||||
return cachedCodePlugin != null;
|
||||
}
|
||||
|
||||
/** Prefetch Shiki only when a message actually contains a code fence. */
|
||||
export function warmAiCodeHighlighter(): Promise<CodeHighlighterPlugin> {
|
||||
codePluginPromise ??= import('./streamdownCodePlugin').then((module) => {
|
||||
cachedCodePlugin = module.streamdownCodePlugin;
|
||||
return module.streamdownCodePlugin;
|
||||
}, (error) => {
|
||||
codePluginPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return codePluginPromise;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
@@ -32,6 +32,8 @@ interface AgentSelectorProps {
|
||||
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
||||
onRediscover?: () => void;
|
||||
onManageAgents?: () => void;
|
||||
parked?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_AGENTS: AgentInfo[] = [
|
||||
@@ -125,10 +127,16 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
onEnableDiscoveredAgent,
|
||||
onRediscover,
|
||||
onManageAgents,
|
||||
parked = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (parked || disabled) setOpen(false);
|
||||
}, [disabled, parked]);
|
||||
|
||||
const enabledExternalAgents = useMemo(
|
||||
() =>
|
||||
externalAgents
|
||||
@@ -200,7 +208,8 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
|
||||
disabled={disabled}
|
||||
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<AgentIconBadge
|
||||
agent={currentAgent}
|
||||
|
||||
@@ -62,6 +62,31 @@ test('expanded composer grows the text area while keeping controls at the bottom
|
||||
assert.doesNotMatch(source, /setExpanded/);
|
||||
});
|
||||
|
||||
test('parked composer closes body-portaled menus', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /parked = false/);
|
||||
assert.match(source, /if \(parked\) closeAllMenus\(\)/);
|
||||
assert.match(source, /becameParked/);
|
||||
});
|
||||
|
||||
test('first keystrokes stay local so IME and Chromium spellcheck cannot stall the composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /spellCheck=\{false\}/);
|
||||
assert.match(source, /autoCorrect="off"/);
|
||||
assert.match(source, /autoCapitalize="off"/);
|
||||
assert.match(source, /nativeEvent\.isComposing/);
|
||||
assert.match(source, /onCompositionEnd=/);
|
||||
assert.match(source, /onBlur=\{\(\) => commitComposerText\(readComposerText\(\)\)\}/);
|
||||
assert.match(source, /onChangeRef\.current\(textareaRef\.current\?\.value \?\? composerTextRef\.current\)/);
|
||||
assert.doesNotMatch(source, /onSend\(\);\s*commitComposerText\(''\);/);
|
||||
assert.match(source, /defaultValue=\{value\}/);
|
||||
assert.match(source, /field-sizing-fixed/);
|
||||
assert.doesNotMatch(source, /value=\{composerText\}/);
|
||||
assert.match(source, /createComposerHasTextStore/);
|
||||
assert.match(source, /ComposerSendUi/);
|
||||
assert.match(source, /chatInputPropsAreEqual/);
|
||||
});
|
||||
|
||||
test('composer resizing also ends when pointer capture is unexpectedly lost', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -192,6 +217,11 @@ test('renders the Catty context usage ring after the model chip', () => {
|
||||
assert.match(html, /aria-valuenow="50"/);
|
||||
});
|
||||
|
||||
test('slash picker system commands clear the local composer', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
|
||||
});
|
||||
|
||||
test('ChatInput wires /compact through getSystemSlashCommand and canCompact', () => {
|
||||
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /getSystemSlashCommand/);
|
||||
|
||||
+267
-61
@@ -20,7 +20,7 @@ import {
|
||||
type UserSkillSlashOption,
|
||||
} from '../../infrastructure/ai/quickMessages';
|
||||
import { SlashCommandPicker } from './SlashCommandPicker';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -38,6 +38,7 @@ import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
||||
import { VariableSizeVirtualList, type VariableSizeVirtualListHandle } from '../ui/VariableSizeVirtualList';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import type { AgentContextUsage } from '../../application/state/useAgentCompactionUi';
|
||||
import { markAiComposerActivity, setAiComposerComposing } from './aiMarkdownWarmup';
|
||||
import {
|
||||
CHAT_INPUT_DEFAULT_HEIGHT,
|
||||
CHAT_INPUT_MAX_HEIGHT,
|
||||
@@ -87,6 +88,87 @@ export interface ProviderSwitcherConfig {
|
||||
onSelect: (providerId: string, modelId: string) => void;
|
||||
}
|
||||
|
||||
type ComposerHasTextStore = {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
get: () => boolean;
|
||||
set: (next: boolean) => void;
|
||||
};
|
||||
|
||||
function createComposerHasTextStore(initial: boolean): ComposerHasTextStore {
|
||||
let value = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
get: () => value,
|
||||
set: (next) => {
|
||||
if (value === next) return;
|
||||
value = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ComposerSendUi = React.memo(function ComposerSendUi({
|
||||
hasTextStore,
|
||||
hasTerminalSelectionAttachment,
|
||||
composerDisabled,
|
||||
disabled,
|
||||
isStreaming,
|
||||
canSteer,
|
||||
isSteering,
|
||||
status,
|
||||
onStop,
|
||||
steerSendingLabel,
|
||||
steerLabel,
|
||||
}: {
|
||||
hasTextStore: ComposerHasTextStore;
|
||||
hasTerminalSelectionAttachment: boolean;
|
||||
composerDisabled: boolean;
|
||||
disabled: boolean;
|
||||
isStreaming: boolean;
|
||||
canSteer: boolean;
|
||||
isSteering: boolean;
|
||||
status: PromptInputStatus;
|
||||
onStop?: () => void;
|
||||
steerSendingLabel: string;
|
||||
steerLabel: string;
|
||||
}) {
|
||||
const hasComposerText = useSyncExternalStore(hasTextStore.subscribe, hasTextStore.get, hasTextStore.get);
|
||||
const sendDisabled = (!hasComposerText && !hasTerminalSelectionAttachment) || disabled;
|
||||
if (isStreaming && canSteer) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!hasComposerText && !hasTerminalSelectionAttachment) || composerDisabled}
|
||||
aria-label={isSteering ? steerSendingLabel : steerLabel}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-foreground/20 bg-foreground text-background shadow-sm transition-colors hover:bg-foreground/90 disabled:border-border/80 disabled:bg-muted/52 disabled:text-foreground/72"
|
||||
>
|
||||
{isSteering ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isSteering ? steerSendingLabel : steerLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputSubmit status="streaming" onStop={onStop} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={sendDisabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -141,6 +223,8 @@ interface ChatInputProps {
|
||||
* `modelPresets` dropdown because their provider is wired inside the CLI.
|
||||
*/
|
||||
providerSwitcher?: ProviderSwitcherConfig;
|
||||
/** Hidden retained panels must not leave body-portaled menus open. */
|
||||
parked?: boolean;
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
@@ -176,10 +260,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
providerSwitcher,
|
||||
parked = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const composerDisabled = disabled || isSteering;
|
||||
const composerTextRef = useRef(value);
|
||||
const hasTextStoreRef = useRef<ComposerHasTextStore | null>(null);
|
||||
if (hasTextStoreRef.current == null) {
|
||||
hasTextStoreRef.current = createComposerHasTextStore(value.trim().length > 0);
|
||||
}
|
||||
const hasTextStore = hasTextStoreRef.current;
|
||||
const pushedParentTextRef = useRef(value);
|
||||
const [composerHeight, setComposerHeight] = useState<number | null>(null);
|
||||
const [composerMaxHeight, setComposerMaxHeight] = useState(CHAT_INPUT_MAX_HEIGHT);
|
||||
const composerDesiredHeightRef = useRef<number | null>(null);
|
||||
@@ -209,6 +301,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setSlashQuery('');
|
||||
setSlashRange(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (parked) closeAllMenus();
|
||||
}, [closeAllMenus, parked]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputShellRef = useRef<HTMLDivElement>(null);
|
||||
const resizeStartRef = useRef<{
|
||||
@@ -320,11 +416,12 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const observer = new ResizeObserver(() => {
|
||||
const maxHeight = resolveVisibleChatInputMaxHeight(panel.clientHeight);
|
||||
if (maxHeight == null) return;
|
||||
setComposerMaxHeight(maxHeight);
|
||||
setComposerHeight(resolveVisibleChatInputHeight(
|
||||
const nextHeight = resolveVisibleChatInputHeight(
|
||||
composerDesiredHeightRef.current,
|
||||
maxHeight,
|
||||
));
|
||||
);
|
||||
setComposerMaxHeight((current) => (current === maxHeight ? current : maxHeight));
|
||||
setComposerHeight((current) => (current === nextHeight ? current : nextHeight));
|
||||
});
|
||||
observer.observe(panel);
|
||||
return () => observer.disconnect();
|
||||
@@ -337,6 +434,48 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
document.body.style.userSelect = resizeStart.previousUserSelect;
|
||||
}, []);
|
||||
|
||||
const syncHasComposerText = useCallback((text: string) => {
|
||||
hasTextStore.set(text.trim().length > 0);
|
||||
}, [hasTextStore]);
|
||||
|
||||
const readComposerText = useCallback(() => (
|
||||
textareaRef.current?.value ?? composerTextRef.current
|
||||
), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === pushedParentTextRef.current) return;
|
||||
pushedParentTextRef.current = value;
|
||||
composerTextRef.current = value;
|
||||
if (textareaRef.current && textareaRef.current.value !== value) {
|
||||
textareaRef.current.value = value;
|
||||
}
|
||||
syncHasComposerText(value);
|
||||
}, [syncHasComposerText, value]);
|
||||
|
||||
const commitComposerText = useCallback((next: string) => {
|
||||
composerTextRef.current = next;
|
||||
pushedParentTextRef.current = next;
|
||||
if (textareaRef.current && textareaRef.current.value !== next) {
|
||||
textareaRef.current.value = next;
|
||||
}
|
||||
syncHasComposerText(next);
|
||||
onChange(next);
|
||||
}, [onChange, syncHasComposerText]);
|
||||
|
||||
const parkedRef = useRef(parked);
|
||||
useEffect(() => {
|
||||
const becameParked = parked && !parkedRef.current;
|
||||
parkedRef.current = parked;
|
||||
if (!becameParked) return;
|
||||
commitComposerText(readComposerText());
|
||||
}, [commitComposerText, parked, readComposerText]);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
useEffect(() => () => {
|
||||
onChangeRef.current(textareaRef.current?.value ?? composerTextRef.current);
|
||||
}, []);
|
||||
|
||||
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
|
||||
const beforeCaret = text.slice(0, caretPosition);
|
||||
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
|
||||
@@ -362,13 +501,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
const handleInputChange = useCallback((newValue: string, composing = false) => {
|
||||
markAiComposerActivity();
|
||||
const previousText = composerTextRef.current;
|
||||
composerTextRef.current = newValue;
|
||||
syncHasComposerText(newValue);
|
||||
if (composing) return;
|
||||
commitComposerText(newValue);
|
||||
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
|
||||
// Detect if user just typed @
|
||||
if (
|
||||
hosts.length > 0 &&
|
||||
newValue.length > value.length &&
|
||||
newValue.length > previousText.length &&
|
||||
newValue.endsWith('@')
|
||||
) {
|
||||
// Position the popover near the textarea
|
||||
@@ -396,18 +540,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
} else if (showSlashCommandPicker) {
|
||||
closeAllMenus();
|
||||
}
|
||||
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos]);
|
||||
}, [commitComposerText, hosts.length, showAtMention, findSlashTrigger, showSlashCommandPicker, closeAllMenus, getInputPanelMenuPos, syncHasComposerText]);
|
||||
|
||||
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
|
||||
// Replace the trailing @ with @hostname
|
||||
const name = host.label || host.hostname;
|
||||
const lastAt = value.lastIndexOf('@');
|
||||
const currentText = readComposerText();
|
||||
const lastAt = currentText.lastIndexOf('@');
|
||||
const newValue = lastAt >= 0
|
||||
? value.slice(0, lastAt) + `@${name} `
|
||||
: value + `@${name} `;
|
||||
onChange(newValue);
|
||||
? currentText.slice(0, lastAt) + `@${name} `
|
||||
: currentText + `@${name} `;
|
||||
commitComposerText(newValue);
|
||||
closeAllMenus();
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
}, [readComposerText, commitComposerText, closeAllMenus]);
|
||||
|
||||
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashCommand') => {
|
||||
const pos = getInputPanelMenuPos();
|
||||
@@ -415,8 +560,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setMenuPos(null);
|
||||
setInputPanelPos(pos);
|
||||
if (menu === 'slashCommand') {
|
||||
const caret = textareaRef.current?.selectionStart ?? value.length;
|
||||
const trigger = findSlashTrigger(value, caret);
|
||||
const currentText = readComposerText();
|
||||
const caret = textareaRef.current?.selectionStart ?? currentText.length;
|
||||
const trigger = findSlashTrigger(currentText, caret);
|
||||
if (trigger) {
|
||||
setSlashQuery(trigger.query);
|
||||
setSlashRange({ start: trigger.start, end: trigger.end });
|
||||
@@ -426,7 +572,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
}
|
||||
setActiveMenu(menu);
|
||||
}, [findSlashTrigger, getInputPanelMenuPos, value]);
|
||||
}, [findSlashTrigger, getInputPanelMenuPos, readComposerText]);
|
||||
|
||||
const userSkillOptions = useMemo<UserSkillSlashOption[]>(
|
||||
() => (lockTurnConfiguration ? [] : userSkills).map((skill) => ({
|
||||
@@ -492,37 +638,39 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const showSlashPickerUI = showSlashCommandPicker && (inputPanelPos != null || menuPos != null);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
const before = value.slice(0, slashRange.start);
|
||||
const after = value.slice(slashRange.end);
|
||||
const currentText = readComposerText();
|
||||
if (!slashRange) return currentText;
|
||||
const before = currentText.slice(0, slashRange.start);
|
||||
const after = currentText.slice(slashRange.end);
|
||||
if (/\s$/.test(before) && /^\s/.test(after)) {
|
||||
return `${before}${after.slice(1)}`;
|
||||
}
|
||||
return `${before}${after}`;
|
||||
}, [slashRange, value]);
|
||||
}, [slashRange, readComposerText]);
|
||||
|
||||
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
|
||||
if (lockTurnConfiguration) return;
|
||||
onAddUserSkill?.(skill.slug);
|
||||
if (slashRange) {
|
||||
onChange(removeSlashQueryFromInput());
|
||||
commitComposerText(removeSlashQueryFromInput());
|
||||
}
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, lockTurnConfiguration, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
}, [closeAllMenus, lockTurnConfiguration, onAddUserSkill, commitComposerText, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
const insertQuickMessage = useCallback((message: AIQuickMessage) => {
|
||||
const currentText = readComposerText();
|
||||
if (slashRange) {
|
||||
const before = value.slice(0, slashRange.start);
|
||||
const after = value.slice(slashRange.end);
|
||||
const before = currentText.slice(0, slashRange.start);
|
||||
const after = currentText.slice(slashRange.end);
|
||||
const spacerBefore = before.length > 0 && !/\s$/.test(before) ? ' ' : '';
|
||||
const spacerAfter = after.length > 0 && !/^\s/.test(after) ? ' ' : '';
|
||||
onChange(`${before}${spacerBefore}${message.content}${spacerAfter}${after}`);
|
||||
commitComposerText(`${before}${spacerBefore}${message.content}${spacerAfter}${after}`);
|
||||
} else {
|
||||
const spacer = value.length > 0 && !/\s$/.test(value) ? ' ' : '';
|
||||
onChange(`${value}${spacer}${message.content}`);
|
||||
const spacer = currentText.length > 0 && !/\s$/.test(currentText) ? ' ' : '';
|
||||
commitComposerText(`${currentText}${spacer}${message.content}`);
|
||||
}
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onChange, slashRange, value]);
|
||||
}, [closeAllMenus, commitComposerText, readComposerText, slashRange]);
|
||||
|
||||
const handleSelectSlashCommandItem = useCallback((item: SlashCommandItem) => {
|
||||
if (item.kind === 'system') {
|
||||
@@ -535,7 +683,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onCompact?.();
|
||||
}
|
||||
if (command === 'stop') onStop?.();
|
||||
onChange('');
|
||||
commitComposerText('');
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
@@ -544,7 +692,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return;
|
||||
}
|
||||
insertUserSkillToken(item.skill);
|
||||
}, [canCompact, closeAllMenus, insertQuickMessage, insertUserSkillToken, onChange, onCompact, onStop]);
|
||||
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onStop]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
@@ -677,17 +825,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(_text: string, _event: FormEvent<HTMLFormElement>) => {
|
||||
const systemCommand = getSystemSlashCommand(value);
|
||||
const submittedText = readComposerText();
|
||||
commitComposerText(submittedText);
|
||||
const systemCommand = getSystemSlashCommand(submittedText);
|
||||
if (systemCommand) {
|
||||
if (systemCommand === 'compact') {
|
||||
if (!canCompact) return;
|
||||
onCompact?.();
|
||||
onChange('');
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
if (systemCommand === 'stop') {
|
||||
onStop?.();
|
||||
onChange('');
|
||||
commitComposerText('');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
@@ -696,9 +846,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
onSteer?.();
|
||||
return;
|
||||
}
|
||||
// Do not empty the box here. handleSend awaits provider sync and may
|
||||
// abort; the parent clears value only after the user message is accepted.
|
||||
onSend();
|
||||
},
|
||||
[canCompact, canSteer, isStreaming, onCompact, onSend, onSteer, onStop, onChange, value],
|
||||
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onSend, onSteer, onStop, readComposerText],
|
||||
);
|
||||
|
||||
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
|
||||
@@ -912,12 +1064,25 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
)}
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
defaultValue={value}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
onChange={(e) => handleInputChange(e.target.value, e.nativeEvent.isComposing)}
|
||||
onFocus={() => markAiComposerActivity()}
|
||||
onCompositionStart={() => setAiComposerComposing(true)}
|
||||
onCompositionUpdate={() => markAiComposerActivity()}
|
||||
onCompositionEnd={(e) => {
|
||||
setAiComposerComposing(false);
|
||||
handleInputChange(e.currentTarget.value);
|
||||
}}
|
||||
onBlur={() => commitComposerText(readComposerText())}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || (isStreaming && canSteer ? t('ai.codex.steer.placeholder') : defaultPlaceholder)}
|
||||
disabled={composerDisabled}
|
||||
className={[
|
||||
'field-sizing-fixed',
|
||||
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
|
||||
composerHeight != null ? 'min-h-0 max-h-none flex-1' : undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
@@ -1420,30 +1585,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{isStreaming && canSteer ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!value.trim() && !hasTerminalSelectionAttachment) || composerDisabled}
|
||||
aria-label={isSteering ? t('ai.codex.steer.sending') : t('ai.codex.steer.addInstruction')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-foreground/20 bg-foreground text-background shadow-sm transition-colors hover:bg-foreground/90 disabled:border-border/80 disabled:bg-muted/52 disabled:text-foreground/72"
|
||||
>
|
||||
{isSteering ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isSteering ? t('ai.codex.steer.sending') : t('ai.codex.steer.addInstruction')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PromptInputSubmit status="streaming" onStop={onStop} />
|
||||
</>
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={(!value.trim() && !hasTerminalSelectionAttachment) || disabled}
|
||||
/>
|
||||
)}
|
||||
<ComposerSendUi
|
||||
hasTextStore={hasTextStore}
|
||||
hasTerminalSelectionAttachment={hasTerminalSelectionAttachment}
|
||||
composerDisabled={composerDisabled}
|
||||
disabled={disabled}
|
||||
isStreaming={isStreaming}
|
||||
canSteer={canSteer}
|
||||
isSteering={isSteering}
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
steerSendingLabel={t('ai.codex.steer.sending')}
|
||||
steerLabel={t('ai.codex.steer.addInstruction')}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
@@ -1452,4 +1606,56 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChatInput);
|
||||
function contextUsageEqual(
|
||||
prev: AgentContextUsage | null | undefined,
|
||||
next: AgentContextUsage | null | undefined,
|
||||
): boolean {
|
||||
if (prev === next) return true;
|
||||
if (!prev || !next) return false;
|
||||
return (
|
||||
prev.sessionId === next.sessionId
|
||||
&& prev.inputTokens === next.inputTokens
|
||||
&& prev.contextWindow === next.contextWindow
|
||||
&& prev.estimated === next.estimated
|
||||
);
|
||||
}
|
||||
|
||||
function chatInputPropsAreEqual(prev: ChatInputProps, next: ChatInputProps): boolean {
|
||||
return (
|
||||
prev.value === next.value
|
||||
&& prev.onChange === next.onChange
|
||||
&& prev.onSend === next.onSend
|
||||
&& prev.onCompact === next.onCompact
|
||||
&& prev.canCompact === next.canCompact
|
||||
&& contextUsageEqual(prev.contextUsage, next.contextUsage)
|
||||
&& prev.onSteer === next.onSteer
|
||||
&& prev.onStop === next.onStop
|
||||
&& prev.isStreaming === next.isStreaming
|
||||
&& prev.canSteer === next.canSteer
|
||||
&& prev.isSteering === next.isSteering
|
||||
&& prev.lockTurnConfiguration === next.lockTurnConfiguration
|
||||
&& prev.disabled === next.disabled
|
||||
&& prev.providerName === next.providerName
|
||||
&& prev.modelName === next.modelName
|
||||
&& prev.agentName === next.agentName
|
||||
&& prev.placeholder === next.placeholder
|
||||
&& prev.modelPresets === next.modelPresets
|
||||
&& prev.selectedModelId === next.selectedModelId
|
||||
&& prev.onModelSelect === next.onModelSelect
|
||||
&& prev.files === next.files
|
||||
&& prev.onAddFiles === next.onAddFiles
|
||||
&& prev.onRemoveFile === next.onRemoveFile
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.selectedUserSkills === next.selectedUserSkills
|
||||
&& prev.userSkills === next.userSkills
|
||||
&& prev.quickMessages === next.quickMessages
|
||||
&& prev.onAddUserSkill === next.onAddUserSkill
|
||||
&& prev.onRemoveUserSkill === next.onRemoveUserSkill
|
||||
&& prev.permissionMode === next.permissionMode
|
||||
&& prev.onPermissionModeChange === next.onPermissionModeChange
|
||||
&& prev.providerSwitcher === next.providerSwitcher
|
||||
&& prev.parked === next.parked
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChatInput, chatInputPropsAreEqual);
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
} from '../ai-elements/conversation';
|
||||
import { LazyMessageResponse } from '../ai-elements/LazyMessageResponse';
|
||||
import { Message, MessageContent } from '../ai-elements/messageShell';
|
||||
import {
|
||||
AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
isAiComposerTyping,
|
||||
scheduleAiMarkdownWarmup,
|
||||
} from './aiMarkdownWarmup';
|
||||
import { ToolCall } from '../ai-elements/tool-call';
|
||||
import ThinkingBlock from './ThinkingBlock';
|
||||
import AgentActivityGroup from './AgentActivityGroup';
|
||||
@@ -371,6 +377,23 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
setPendingJumpMessageId(null);
|
||||
}, [activeSessionId]);
|
||||
|
||||
const hasAssistantMarkdown = useMemo(
|
||||
() => messages.some((message) => message.role === 'assistant' && Boolean(message.content)),
|
||||
[messages],
|
||||
);
|
||||
|
||||
// Do not start Streamdown on expand. Import cannot be cancelled, and idle
|
||||
// right after open collides with the first few keystrokes. History stays
|
||||
// plaintext until send, composer blur, or a long unfocused delay.
|
||||
useEffect(() => {
|
||||
if (!hasAssistantMarkdown) return undefined;
|
||||
return scheduleAiMarkdownWarmup({
|
||||
isBusy: isAiComposerTyping,
|
||||
initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
});
|
||||
}, [hasAssistantMarkdown]);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
() => messages.filter((message) => message.role !== 'system'),
|
||||
[messages],
|
||||
@@ -714,7 +737,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<div data-ai-content="markdown">
|
||||
<LazyMessageResponse isAnimating={!!isThisStreaming}>
|
||||
<LazyMessageResponse deferUntilWarm isAnimating={!!isThisStreaming}>
|
||||
{message.content}
|
||||
</LazyMessageResponse>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
AI_COMPOSER_IDLE_MS,
|
||||
AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
isAiComposerBusy,
|
||||
isAiComposerTarget,
|
||||
markAiComposerActivity,
|
||||
resolveAiMarkdownWarmupDelay,
|
||||
shouldDeferAiMarkdownWarmup,
|
||||
} from './aiMarkdownWarmup';
|
||||
|
||||
test('defers markdown warmup while the composer is focused, composing, or recently active', () => {
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({}), false);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ isComposing: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ recentlyActive: true }), true);
|
||||
});
|
||||
|
||||
test('recent composer activity counts as busy', () => {
|
||||
markAiComposerActivity();
|
||||
assert.equal(isAiComposerBusy(), true);
|
||||
assert.ok(AI_COMPOSER_IDLE_MS >= 2000);
|
||||
});
|
||||
|
||||
test('recognizes the chat composer as a busy warmup target', () => {
|
||||
const body = { closest: (sel: string) => (sel.includes('ai-chat-input-body') ? {} : null) };
|
||||
assert.equal(isAiComposerTarget(body as unknown as EventTarget), true);
|
||||
assert.equal(isAiComposerTarget(null), false);
|
||||
});
|
||||
|
||||
test('composer-idle IPC waits the same expand grace as history markdown', () => {
|
||||
const warmup = readFileSync(new URL('./aiMarkdownWarmup.ts', import.meta.url), 'utf8');
|
||||
assert.match(warmup, /initialDelayMs:\s*options\?\.initialDelayMs \?\? AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS/);
|
||||
assert.match(warmup, /isBusy: isAiComposerBusy/);
|
||||
assert.match(warmup, /markAiComposerActivity\(\);\n\s*arm\(\);/);
|
||||
assert.match(warmup, /if \(isAiComposerTyping\(\)\) \{\s*hydrateScheduled = true;/s);
|
||||
});
|
||||
|
||||
test('history markdown waits after expand, then resumes quickly after blur', () => {
|
||||
assert.equal(AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS, 4000);
|
||||
assert.equal(AI_MARKDOWN_WARMUP_RESUME_DELAY_MS, 600);
|
||||
assert.equal(resolveAiMarkdownWarmupDelay({
|
||||
hasArmed: false,
|
||||
initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
}), 4000);
|
||||
assert.equal(resolveAiMarkdownWarmupDelay({
|
||||
hasArmed: true,
|
||||
initialDelayMs: AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: AI_MARKDOWN_WARMUP_RESUME_DELAY_MS,
|
||||
}), 600);
|
||||
});
|
||||
|
||||
test('AI panel no longer starts Streamdown just because it became visible', () => {
|
||||
const panel = readFileSync(new URL('../AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(panel, /scheduleAiMarkdownWarmup/);
|
||||
assert.match(panel, /warmAiMarkdownRenderer/);
|
||||
assert.doesNotMatch(panel, /timeout:\s*2500/);
|
||||
assert.doesNotMatch(panel, /import\('\.\/ai-elements\/messageResponse'\)/);
|
||||
assert.doesNotMatch(panel, /@streamdown\/code/);
|
||||
});
|
||||
|
||||
test('chat history defers Streamdown until warmup is already done', () => {
|
||||
const list = readFileSync(new URL('./ChatMessageList.tsx', import.meta.url), 'utf8');
|
||||
assert.match(list, /deferUntilWarm/);
|
||||
assert.match(list, /scheduleAiMarkdownWarmup/);
|
||||
assert.match(list, /isAiComposerTyping/);
|
||||
assert.match(list, /AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS/);
|
||||
assert.match(list, /AI_MARKDOWN_WARMUP_RESUME_DELAY_MS/);
|
||||
});
|
||||
|
||||
test('composer focus alone does not count as typing', () => {
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ composerFocused: true, isComposing: false, recentlyActive: false }), true);
|
||||
assert.equal(shouldDeferAiMarkdownWarmup({ isComposing: false, recentlyActive: false }), false);
|
||||
});
|
||||
|
||||
test('LazyMessageResponse keeps plaintext while chat asks to defer', () => {
|
||||
const source = readFileSync(new URL('../ai-elements/LazyMessageResponse.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /deferUntilWarm/);
|
||||
assert.match(source, /enqueueChatMarkdownHydrate/);
|
||||
assert.match(source, /isAiMarkdownRendererReady/);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
const AI_COMPOSER_FOCUS_SELECTOR =
|
||||
'[data-section="ai-chat-input-body"], [data-section="ai-chat-panel"] textarea';
|
||||
|
||||
/** Wait after expand before history markdown can start — covers the type-a-few-chars window. */
|
||||
export const AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS = 4000;
|
||||
/** After the composer blurs, a short pause is enough to know typing stopped. */
|
||||
export const AI_MARKDOWN_WARMUP_RESUME_DELAY_MS = 600;
|
||||
/** Treat recent keystrokes as busy even if focus already moved. */
|
||||
export const AI_COMPOSER_IDLE_MS = 2000;
|
||||
let composerComposing = false;
|
||||
const CHAT_MARKDOWN_HYDRATE_BATCH = 2;
|
||||
|
||||
let markdownWarmupPromise: Promise<unknown> | null = null;
|
||||
let markdownWarmupResolved = false;
|
||||
let lastComposerActivityAt = 0;
|
||||
const readyListeners = new Set<() => void>();
|
||||
const hydrateQueue: Array<() => void> = [];
|
||||
let hydrateScheduled = false;
|
||||
|
||||
function notifyAiMarkdownRendererReady(): void {
|
||||
markdownWarmupResolved = true;
|
||||
for (const listener of readyListeners) listener();
|
||||
readyListeners.clear();
|
||||
pumpChatMarkdownHydrate();
|
||||
}
|
||||
|
||||
export function isAiComposerTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as Element).closest !== 'function') return false;
|
||||
return Boolean((target as Element).closest(AI_COMPOSER_FOCUS_SELECTOR));
|
||||
}
|
||||
|
||||
export function markAiComposerActivity(): void {
|
||||
lastComposerActivityAt = Date.now();
|
||||
}
|
||||
|
||||
export function setAiComposerComposing(next: boolean): void {
|
||||
composerComposing = next;
|
||||
if (next) markAiComposerActivity();
|
||||
}
|
||||
|
||||
export function isAiComposerRecentlyActive(now = Date.now()): boolean {
|
||||
return lastComposerActivityAt > 0 && now - lastComposerActivityAt < AI_COMPOSER_IDLE_MS;
|
||||
}
|
||||
|
||||
export function shouldDeferAiMarkdownWarmup(input: {
|
||||
composerFocused?: boolean;
|
||||
isComposing?: boolean;
|
||||
recentlyActive?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(input.composerFocused || input.isComposing || input.recentlyActive);
|
||||
}
|
||||
|
||||
export function isAiComposerTyping(): boolean {
|
||||
return shouldDeferAiMarkdownWarmup({
|
||||
isComposing: composerComposing,
|
||||
recentlyActive: isAiComposerRecentlyActive(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isAiComposerBusy(): boolean {
|
||||
const active = typeof document === 'undefined' ? null : document.activeElement;
|
||||
return shouldDeferAiMarkdownWarmup({
|
||||
composerFocused: isAiComposerTarget(active),
|
||||
isComposing: composerComposing,
|
||||
recentlyActive: isAiComposerRecentlyActive(),
|
||||
});
|
||||
}
|
||||
|
||||
export function isAiMarkdownRendererReady(): boolean {
|
||||
return markdownWarmupResolved;
|
||||
}
|
||||
|
||||
export function subscribeAiMarkdownRendererReady(listener: () => void): () => void {
|
||||
if (markdownWarmupResolved) {
|
||||
listener();
|
||||
return () => {};
|
||||
}
|
||||
readyListeners.add(listener);
|
||||
return () => {
|
||||
readyListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAiMarkdownWarmupDelay(input: {
|
||||
hasArmed: boolean;
|
||||
initialDelayMs: number;
|
||||
resumeDelayMs: number;
|
||||
}): number {
|
||||
return input.hasArmed ? input.resumeDelayMs : input.initialDelayMs;
|
||||
}
|
||||
|
||||
/** Prefetch Streamdown off the first-keystroke path. Safe to call repeatedly. */
|
||||
export function warmAiMarkdownRenderer(): Promise<unknown> {
|
||||
markdownWarmupPromise ??= import('../ai-elements/messageResponse').then((module) => {
|
||||
notifyAiMarkdownRendererReady();
|
||||
return module;
|
||||
}, (error) => {
|
||||
markdownWarmupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return markdownWarmupPromise;
|
||||
}
|
||||
|
||||
function pumpChatMarkdownHydrate(): void {
|
||||
if (hydrateScheduled) return;
|
||||
hydrateScheduled = true;
|
||||
const run = () => {
|
||||
hydrateScheduled = false;
|
||||
if (hydrateQueue.length === 0) return;
|
||||
if (!markdownWarmupResolved) return;
|
||||
// Focused but idle is fine: Streamdown+CJK is light. Only pause while
|
||||
// the user is actually typing so history does not stay raw forever.
|
||||
if (isAiComposerTyping()) {
|
||||
hydrateScheduled = true;
|
||||
window.setTimeout(() => {
|
||||
hydrateScheduled = false;
|
||||
pumpChatMarkdownHydrate();
|
||||
}, AI_COMPOSER_IDLE_MS);
|
||||
return;
|
||||
}
|
||||
const batch = hydrateQueue.splice(0, CHAT_MARKDOWN_HYDRATE_BATCH);
|
||||
for (const task of batch) task();
|
||||
if (hydrateQueue.length > 0) {
|
||||
pumpChatMarkdownHydrate();
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(run);
|
||||
return;
|
||||
}
|
||||
queueMicrotask(run);
|
||||
}
|
||||
|
||||
/** Upgrade deferred chat rows a few at a time, never while the composer is busy. */
|
||||
export function enqueueChatMarkdownHydrate(onReady: () => void): () => void {
|
||||
let cancelled = false;
|
||||
const task = () => {
|
||||
if (!cancelled) onReady();
|
||||
};
|
||||
hydrateQueue.push(task);
|
||||
pumpChatMarkdownHydrate();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const index = hydrateQueue.indexOf(task);
|
||||
if (index >= 0) hydrateQueue.splice(index, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` only when the composer is idle. Import/IPC cannot be cancelled
|
||||
* once started, so this must not fire during expand → first type.
|
||||
*/
|
||||
export function scheduleWhenAiComposerIdle(
|
||||
task: () => void,
|
||||
options?: {
|
||||
initialDelayMs?: number;
|
||||
resumeDelayMs?: number;
|
||||
},
|
||||
): () => void {
|
||||
return scheduleAiMarkdownWarmup({
|
||||
load: task,
|
||||
isBusy: isAiComposerBusy,
|
||||
initialDelayMs: options?.initialDelayMs ?? AI_MARKDOWN_WARMUP_INITIAL_DELAY_MS,
|
||||
resumeDelayMs: options?.resumeDelayMs ?? AI_COMPOSER_IDLE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load markdown only when the browser is idle and the composer is not focused.
|
||||
* Import() cannot be cancelled, so this must not start during expand → first type.
|
||||
*/
|
||||
export function scheduleAiMarkdownWarmup(options?: {
|
||||
isBusy?: () => boolean;
|
||||
load?: () => void;
|
||||
initialDelayMs?: number;
|
||||
resumeDelayMs?: number;
|
||||
}): () => void {
|
||||
const isBusy = options?.isBusy ?? isAiComposerBusy;
|
||||
const load = options?.load ?? (() => {
|
||||
void warmAiMarkdownRenderer();
|
||||
});
|
||||
const initialDelayMs = options?.initialDelayMs ?? 0;
|
||||
const resumeDelayMs = options?.resumeDelayMs ?? 0;
|
||||
|
||||
let idleId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
let cancelled = false;
|
||||
let hasArmed = false;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (idleId != null && typeof cancelIdleCallback === 'function') {
|
||||
cancelIdleCallback(idleId);
|
||||
}
|
||||
idleId = null;
|
||||
if (timeoutId != null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const tryLoad = () => {
|
||||
idleId = null;
|
||||
timeoutId = null;
|
||||
if (cancelled) return;
|
||||
if (isBusy()) {
|
||||
timeoutId = window.setTimeout(armIdle, Math.max(resumeDelayMs, AI_COMPOSER_IDLE_MS));
|
||||
return;
|
||||
}
|
||||
load();
|
||||
};
|
||||
|
||||
const armIdle = () => {
|
||||
if (cancelled) return;
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
idleId = requestIdleCallback(tryLoad);
|
||||
return;
|
||||
}
|
||||
timeoutId = window.setTimeout(tryLoad, 2000);
|
||||
};
|
||||
|
||||
const arm = () => {
|
||||
if (cancelled) return;
|
||||
clearTimers();
|
||||
const delay = resolveAiMarkdownWarmupDelay({
|
||||
hasArmed,
|
||||
initialDelayMs,
|
||||
resumeDelayMs,
|
||||
});
|
||||
hasArmed = true;
|
||||
if (delay > 0) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timeoutId = null;
|
||||
armIdle();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
armIdle();
|
||||
};
|
||||
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (isAiComposerTarget(event.target)) {
|
||||
markAiComposerActivity();
|
||||
arm();
|
||||
}
|
||||
};
|
||||
const onFocusOut = (event: FocusEvent) => {
|
||||
if (isAiComposerTarget(event.target)) arm();
|
||||
};
|
||||
const onComposerEvent = (event: Event) => {
|
||||
if (!isAiComposerTarget(event.target)) return;
|
||||
markAiComposerActivity();
|
||||
arm();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('focusin', onFocusIn);
|
||||
window.addEventListener('focusout', onFocusOut);
|
||||
window.addEventListener('keydown', onComposerEvent, true);
|
||||
window.addEventListener('compositionstart', onComposerEvent, true);
|
||||
window.addEventListener('compositionupdate', onComposerEvent, true);
|
||||
}
|
||||
arm();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimers();
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('focusin', onFocusIn);
|
||||
window.removeEventListener('focusout', onFocusOut);
|
||||
window.removeEventListener('keydown', onComposerEvent, true);
|
||||
window.removeEventListener('compositionstart', onComposerEvent, true);
|
||||
window.removeEventListener('compositionupdate', onComposerEvent, true);
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -161,6 +161,16 @@ export const SettingsSection: React.FC<{
|
||||
</section>
|
||||
);
|
||||
|
||||
/** Footer note under a SettingCard. Keep a gap so it does not kiss the card. */
|
||||
export const settingHintClassName = "mt-3 text-xs text-muted-foreground";
|
||||
|
||||
export const SettingHint: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className }) => (
|
||||
<p className={cn(settingHintClassName, className)}>{children}</p>
|
||||
);
|
||||
|
||||
export const settingCardClassName = "rounded-lg border bg-card";
|
||||
|
||||
interface SettingCardProps {
|
||||
|
||||
@@ -2,6 +2,20 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("system setting hints sit below their cards with a shared top gap", () => {
|
||||
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
|
||||
const systemSource = readFileSync(new URL("./tabs/SettingsSystemTab.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(uiSource, /export const settingHintClassName = "mt-3 text-xs text-muted-foreground"/);
|
||||
assert.match(uiSource, /export const SettingHint/);
|
||||
assert.match(systemSource, /SettingHint/);
|
||||
assert.match(systemSource, /settings\.system\.crashLogs\.hint/);
|
||||
assert.doesNotMatch(
|
||||
systemSource,
|
||||
/<p className="text-xs text-muted-foreground">\s*\{t\("settings\.system\.crashLogs\.hint"\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("settings search focus scrolls the content pane, not the window viewport", () => {
|
||||
const source = readFileSync(new URL("./settingsFocus.ts", import.meta.url), "utf8");
|
||||
const uiSource = readFileSync(new URL("./settings-ui.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingsAnchor, SettingsTabContent } from "../settings-ui";
|
||||
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingHint, SettingsAnchor, SettingsTabContent } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface CrashLogFile {
|
||||
@@ -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,
|
||||
@@ -565,7 +569,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
{t('settings.update.lastCheckedPrefix')}
|
||||
@@ -574,7 +578,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</span>
|
||||
)}
|
||||
{t('settings.update.hint')}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.system.networkProxy.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
@@ -635,9 +639,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</>
|
||||
)}
|
||||
</SettingCard>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.networkProxy.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.system.credentials.title")} />
|
||||
<SettingsAnchor anchorId="system-credentials">
|
||||
@@ -828,9 +832,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.crashLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsAnchor>
|
||||
|
||||
<SectionHeader title={t("settings.system.tempDirectory")} />
|
||||
@@ -911,13 +915,31 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.system.tempDirectoryHint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsAnchor>
|
||||
|
||||
<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")}
|
||||
@@ -1046,9 +1068,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t('settings.sshDeepLink.title')} />
|
||||
<SettingCard>
|
||||
@@ -1161,9 +1183,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.sshDebugLogs.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
|
||||
<SectionHeader title={t("settings.globalHotkey.title")} />
|
||||
<SettingCard className="space-y-4 py-4">
|
||||
@@ -1236,9 +1258,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingHint>
|
||||
{t("settings.globalHotkey.hint")}
|
||||
</p>
|
||||
</SettingHint>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
};
|
||||
|
||||
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user