mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b9a4f5d1 | ||
|
|
f08ca1c08a | ||
|
|
c6baebd514 | ||
|
|
34c3c5beca | ||
|
|
34eb3cd8fc | ||
|
|
3d939480c9 | ||
|
|
6400ce1e75 | ||
|
|
6cda26fad7 | ||
|
|
3deeb74ca7 | ||
|
|
476e441057 | ||
|
|
965c1c581a | ||
|
|
99e1a8d6d8 | ||
|
|
0558dd6f6d | ||
|
|
86e2336370 | ||
|
|
6f7d18ffba | ||
|
|
6cb68bf6fc | ||
|
|
bccc085029 | ||
|
|
2d95c71b85 | ||
|
|
effafd9c2e | ||
|
|
aebdcedda9 | ||
|
|
74b05c83d0 | ||
|
|
94e359f6d7 | ||
|
|
101ff24a1f | ||
|
|
9fe1fc7aa4 | ||
|
|
1dbf46e32f | ||
|
|
6e3401f9dc | ||
|
|
6944b77ec7 | ||
|
|
3f2e33f6e0 | ||
|
|
2b9846d1d2 | ||
|
|
b6a7f40175 | ||
|
|
aa4b7f46e5 | ||
|
|
c79d51858a | ||
|
|
e430a42dbe | ||
|
|
5dec129088 | ||
|
|
f299271b1b | ||
|
|
49787215d2 | ||
|
|
5e34538c55 | ||
|
|
862c3f6fb5 | ||
|
|
2edbeae02c | ||
|
|
02ada0c61d | ||
|
|
98e95c36dc | ||
|
|
a1b1c37f36 | ||
|
|
45905f8f34 | ||
|
|
6d83b32815 | ||
|
|
a366cf81d9 | ||
|
|
3a8bd7eea7 | ||
|
|
a46c3321db | ||
|
|
eedb123b3a | ||
|
|
1c7dc33852 | ||
|
|
49a3c31de8 | ||
|
|
82e318c17f | ||
|
|
920e3cf8ec | ||
|
|
2fe7de6cd7 | ||
|
|
f3ac99e587 | ||
|
|
fe7a867f38 | ||
|
|
7d70d85d23 | ||
|
|
5a70e9d6b8 | ||
|
|
65b00ae12f | ||
|
|
c36ff6bd49 | ||
|
|
269b4d7a7a | ||
|
|
3c499de5fa | ||
|
|
c2ce086027 | ||
|
|
fc16da92d1 | ||
|
|
49bd0ad1ae | ||
|
|
6462fe8db4 | ||
|
|
b118230218 | ||
|
|
cc5f2bdd7f | ||
|
|
ab309c0db2 | ||
|
|
6346754ad4 | ||
|
|
3c72bbad39 | ||
|
|
9ae973b87a | ||
|
|
49f79f0897 | ||
|
|
21373fc67f | ||
|
|
fd3552d8ef | ||
|
|
2f7059fbbf | ||
|
|
73429565c5 | ||
|
|
a7d7039317 | ||
|
|
e389cce5a2 | ||
|
|
debae7f89e | ||
|
|
172466a2c7 | ||
|
|
da81caa72c | ||
|
|
91610bbbaa | ||
|
|
9562f909e1 | ||
|
|
878f9bfbb7 | ||
|
|
5df248b5ea | ||
|
|
d019ded7bf | ||
|
|
354a7000b4 | ||
|
|
5271f64402 | ||
|
|
5665578b2e | ||
|
|
b0b88bb3a2 | ||
|
|
8d324a2afb | ||
|
|
433927fcfe | ||
|
|
fb78596c85 | ||
|
|
546eb9129a | ||
|
|
24c978cf4d | ||
|
|
74a8f4d32d | ||
|
|
b4151c61d6 | ||
|
|
0b94a82579 | ||
|
|
fce196599a | ||
|
|
c43af71572 | ||
|
|
cf6ebadb08 | ||
|
|
d027207bed | ||
|
|
109c8075c2 | ||
|
|
2fcda46801 | ||
|
|
12fdd1c724 | ||
|
|
1a52a0b846 | ||
|
|
26e3c7c2b7 | ||
|
|
b88f7a0bfd | ||
|
|
c1635a0f55 | ||
|
|
986551f146 | ||
|
|
2796975526 | ||
|
|
0cbb377385 | ||
|
|
ee3dce4cc5 | ||
|
|
854ae0d6f2 | ||
|
|
a20bb21af8 | ||
|
|
2fbc78fdc0 | ||
|
|
32d9824eb9 | ||
|
|
98ead6af1f | ||
|
|
595f42c84d | ||
|
|
025d0cec08 | ||
|
|
6ba594800f | ||
|
|
16caa48a2a | ||
|
|
cdcacaef28 | ||
|
|
90b6e514ef | ||
|
|
c10debea35 | ||
|
|
d6f98948e0 | ||
|
|
7770e86743 | ||
|
|
1f7d2f91c9 | ||
|
|
05231d3fba | ||
|
|
d534b80532 | ||
|
|
acd39df54b |
No files matched your search
@@ -0,0 +1,40 @@
|
||||
## Summary
|
||||
|
||||
<!-- Briefly describe what this PR does and why. -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code cleanup
|
||||
- [ ] Documentation update
|
||||
- [ ] Build / CI change
|
||||
- [ ] Other (please describe):
|
||||
|
||||
## Related Issue (optional)
|
||||
|
||||
N/A
|
||||
|
||||
<!-- If this PR has a related issue, replace N/A with "Closes #123" or "Related to #123". -->
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the key changes in this PR. -->
|
||||
|
||||
## Screenshots / Demo
|
||||
|
||||
<!-- If this PR affects UI or behavior, include screenshots or a short description. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] I have tested these changes locally (`npm run dev`)
|
||||
- [ ] Linting passes (`npm run lint`)
|
||||
- [ ] Tests pass (`npm test`)
|
||||
- [ ] Generated capability tool specs are updated when applicable (`npm run generate:capability-tools`)
|
||||
- [ ] No new console errors or warnings, if this affects app behavior
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the existing project style
|
||||
- [ ] I have added or updated relevant documentation
|
||||
- [ ] I have not introduced any breaking changes (or I have described them above)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We actively maintain the latest stable release of Netcatty. Security fixes are applied to the current release only.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| Older | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Netcatty, **please do not open a public GitHub Issue**.
|
||||
|
||||
Instead, report it privately via one of the following methods:
|
||||
|
||||
- **GitHub Private Vulnerability Reporting**: Use the [Security tab](https://github.com/binaricat/Netcatty/security/advisories/new) to submit a private advisory.
|
||||
- **Email**: Send details to support@netcatty.com.
|
||||
- **GitHub Issues** (for non-sensitive security concerns only): https://github.com/binaricat/Netcatty/issues
|
||||
|
||||
Please include the following details in your report:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
- The potential impact
|
||||
- Any suggested mitigation or fix
|
||||
|
||||
## Response Timeline
|
||||
|
||||
We aim to:
|
||||
|
||||
- Acknowledge receipt within **3 business days**
|
||||
- Provide a status update within **7 business days**
|
||||
- Release a patch as soon as possible depending on severity
|
||||
|
||||
## Scope
|
||||
|
||||
This policy applies to the Netcatty desktop application (`netcatty.app`) and its source code in this repository.
|
||||
|
||||
Vulnerabilities in third-party dependencies should be reported directly to the respective upstream projects.
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow responsible disclosure. Once a fix is available, we will publish a security advisory and credit the reporter (unless they prefer to remain anonymous).
|
||||
@@ -0,0 +1,76 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function usage() {
|
||||
console.error('Usage: node .github/scripts/update-nix-release.js --artifacts <dir> --version <semver>');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--artifacts' || arg === '--version') {
|
||||
args[arg.slice(2)] = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
if (!args.artifacts || !args.version) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function sriSha256(filePath) {
|
||||
const digest = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('base64');
|
||||
return `sha256-${digest}`;
|
||||
}
|
||||
|
||||
function findArtifact(artifactsDir, fileName) {
|
||||
const filePath = path.join(artifactsDir, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Missing release artifact: ${filePath}`);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function renderReleaseNix({ version, x64Hash, arm64Hash }) {
|
||||
return `{
|
||||
version = "${version}";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "${x64Hash}";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "${arm64Hash}";
|
||||
};
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv);
|
||||
const version = args.version.replace(/^v/, '');
|
||||
|
||||
if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-.+)?$/.test(version)) {
|
||||
throw new Error(`Expected semver version, got: ${args.version}`);
|
||||
}
|
||||
|
||||
const x64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-x86_64.AppImage`);
|
||||
const arm64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-arm64.AppImage`);
|
||||
|
||||
const releaseNix = renderReleaseNix({
|
||||
version,
|
||||
x64Hash: sriSha256(x64AppImage),
|
||||
arm64Hash: sriSha256(arm64AppImage),
|
||||
});
|
||||
|
||||
fs.writeFileSync('nix/release.nix', releaseNix);
|
||||
console.log(`Updated nix/release.nix for Netcatty ${version}`);
|
||||
@@ -411,8 +411,8 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install pacman packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
- name: Install Linux packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
@@ -453,6 +453,9 @@ jobs:
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh amd64
|
||||
|
||||
- name: Verify packaged rpm artifact
|
||||
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -566,6 +569,9 @@ jobs:
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh arm64
|
||||
|
||||
- name: Verify packaged rpm artifact
|
||||
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -687,6 +693,46 @@ jobs:
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
update-nix-release:
|
||||
name: update Nix release metadata
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref_name, '-')
|
||||
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Update Nix release metadata
|
||||
run: node .github/scripts/update-nix-release.js --artifacts artifacts --version "${GITHUB_REF_NAME#v}"
|
||||
|
||||
- name: Commit Nix release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
if git diff --quiet -- nix/release.nix; then
|
||||
echo "Nix release metadata is already current."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/release.nix
|
||||
git commit -m "Update Nix release metadata for ${GITHUB_REF_NAME}"
|
||||
git push origin HEAD:${{ github.event.repository.default_branch }}
|
||||
|
||||
homebrew-tap:
|
||||
name: bump homebrew tap
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -25,7 +25,9 @@ import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { upsertKnownHost } from './domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ConfirmDialog } from './components/ui/confirm-dialog';
|
||||
import { PortForwardHostKeyDialog } from './components/port-forwarding';
|
||||
import { VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
@@ -72,7 +75,7 @@ import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
@@ -109,11 +112,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Navigation state for VaultView sections
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
|
||||
const [ephemeralHosts, setEphemeralHosts] = useState<Host[]>([]);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
|
||||
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
|
||||
|
||||
const {
|
||||
@@ -337,6 +343,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const terminalHosts = useMemo(
|
||||
() => (ephemeralHosts.length > 0 ? [...hosts, ...ephemeralHosts] : hosts),
|
||||
[hosts, ephemeralHosts],
|
||||
);
|
||||
const ephemeralHostIds = useMemo(
|
||||
() => new Set(ephemeralHosts.map((host) => host.id)),
|
||||
[ephemeralHosts],
|
||||
);
|
||||
const sessionById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions],
|
||||
@@ -557,6 +571,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
|
||||
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
|
||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
@@ -589,7 +604,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
_handleTrayJumpToSession(sessionId);
|
||||
});
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
|
||||
_handleTrayPanelConnect(hostId);
|
||||
_handleTrayPanelConnectRequest(hostId);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
@@ -597,6 +612,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: _handleTrayPanelConnect,
|
||||
pendingHostIds: pendingTrayPanelConnectHostIds,
|
||||
setPendingHostIds: setPendingTrayPanelConnectHostIds,
|
||||
}));
|
||||
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
|
||||
|
||||
// Handle keyboard-interactive submit
|
||||
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
|
||||
|
||||
@@ -893,10 +917,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleDeleteHost = useCallback((hostId: string) => {
|
||||
const target = hosts.find(h => h.id === hostId);
|
||||
const confirmed = window.confirm(t('confirm.deleteHost', { name: target?.label || hostId }));
|
||||
if (!confirmed) return;
|
||||
updateHosts(hosts.filter(h => h.id !== hostId));
|
||||
}, [hosts, updateHosts, t]);
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
if (!deleteHostConfirm) return;
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
|
||||
const handleAddKnownHost = useCallback((kh: KnownHost) => {
|
||||
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
|
||||
@@ -929,7 +957,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, []);
|
||||
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => { return handleCreateLocalTerminalImpl(() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }), shell); }, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
const handleCreateLocalTerminal = useCallback((
|
||||
shell?: { command: string; args?: string[]; name?: string; icon?: string },
|
||||
options?: { localStartDir?: string },
|
||||
) => {
|
||||
return handleCreateLocalTerminalImpl(
|
||||
() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }),
|
||||
shell,
|
||||
options,
|
||||
);
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
@@ -985,6 +1022,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
};
|
||||
});
|
||||
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
|
||||
|
||||
if (target.password) {
|
||||
// One-time-password link: connect ephemerally with exactly the URL
|
||||
// credentials. A uniquely matched saved host still contributes its
|
||||
// non-credential settings (proxy, jump chain, charset, ...). Build
|
||||
// from the group-resolved effective host so the builder can clear
|
||||
// `group` and block group credential inheritance from later
|
||||
// effective-host resolution.
|
||||
const draftOptions = { id: crypto.randomUUID(), now: Date.now() };
|
||||
const ephemeralHost = matchedEffectiveHost
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
@@ -1008,6 +1062,60 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const rawUrl = payload?.url || '';
|
||||
const target = parseJmsDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.jms.invalid'));
|
||||
return;
|
||||
}
|
||||
if (!isSupportedJmsProtocol(target.protocol)) {
|
||||
toast.warning(t('deepLink.jms.unsupported', { protocol: target.protocol }));
|
||||
return;
|
||||
}
|
||||
const ephemeralHost = buildJmsDeepLinkEphemeralHost(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onJmsDeepLink) return;
|
||||
return bridge.onJmsDeepLink((payload) => {
|
||||
_handleJmsDeepLink(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
setEphemeralHosts((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const referencedHostIds = new Set(
|
||||
sessions.map((session) => session.hostId).filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const next = prev.filter((host) => referencedHostIds.has(host.id));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onOpenTerminalPath) return;
|
||||
return bridge.onOpenTerminalPath((payload) => {
|
||||
_handleOpenTerminalPath(payload);
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
|
||||
const tabId = handleConnectToHost(host);
|
||||
if (source?.noteId && typeof tabId === 'string' && tabId) {
|
||||
@@ -1073,6 +1181,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
)));
|
||||
}, [hosts, updateHosts]);
|
||||
|
||||
// Terminal-layer host updates may include ephemeral deep-link hosts; keep
|
||||
// those in memory only and never let them reach the persisted vault.
|
||||
const updateTerminalHosts = useCallback((nextHosts: Host[]) => {
|
||||
const { vaultHosts, ephemeralHosts: updatedEphemeralHosts } =
|
||||
splitHostsUpdateByEphemeral(nextHosts, ephemeralHostIds);
|
||||
if (updatedEphemeralHosts.length > 0) {
|
||||
setEphemeralHosts((prev) => applyEphemeralHostsUpdate(prev, updatedEphemeralHosts));
|
||||
}
|
||||
updateHosts(vaultHosts);
|
||||
}, [ephemeralHostIds, updateHosts]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -1178,6 +1297,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return (
|
||||
<>
|
||||
<PortForwardHostKeyDialog onAddKnownHost={handleAddKnownHost} />
|
||||
<ConfirmDialog
|
||||
open={deleteHostConfirm !== null}
|
||||
title={deleteHostConfirm ? t('confirm.deleteHost', { name: deleteHostConfirm.name }) : ''}
|
||||
confirmLabel={t('action.delete')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteHostConfirm(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteHost}
|
||||
/>
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
@@ -1195,7 +1324,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported privately to the community leaders responsible for enforcement at
|
||||
support@netcatty.com. Please do not report Code of Conduct incidents through
|
||||
public GitHub issues.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Contributing to Netcatty
|
||||
|
||||
Thank you for your interest in contributing to Netcatty — an AI-powered SSH client, SFTP browser, and terminal manager built with Electron, React, and xterm.js.
|
||||
|
||||
Please read this guide before submitting issues or pull requests.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project follows the [Contributor Covenant Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you agree to uphold this standard.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- Report bugs via [GitHub Issues](https://github.com/binaricat/Netcatty/issues)
|
||||
- Suggest features or improvements
|
||||
- Fix bugs or implement features listed in Issues
|
||||
- Improve documentation
|
||||
|
||||
## Development Setup
|
||||
|
||||
**Prerequisites:** Node.js 22+ and npm.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/binaricat/Netcatty.git
|
||||
cd Netcatty
|
||||
|
||||
# Install dependencies
|
||||
npm ci
|
||||
|
||||
# Start in development mode (Vite + Electron)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build & Package
|
||||
|
||||
```bash
|
||||
npm run build # Build for production
|
||||
npm run pack # Package for current platform
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS, portable, ZIP)
|
||||
npm run pack:linux # Linux (AppImage, DEB, RPM, pacman)
|
||||
```
|
||||
|
||||
## Linting and Tests
|
||||
|
||||
```bash
|
||||
npm run lint # Run ESLint
|
||||
npm test # Run the test suite
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a feature branch:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
3. Make your changes, following the existing project style.
|
||||
4. Commit with a clear message:
|
||||
```bash
|
||||
git commit -m 'feat: add amazing feature'
|
||||
```
|
||||
5. Push the branch and open a Pull Request against `main`.
|
||||
6. Run `npm run lint` and `npm test` before requesting review.
|
||||
7. If you changed the capability catalog, run `npm run generate:capability-tools`
|
||||
and commit any generated updates.
|
||||
8. Ensure all CI checks pass before requesting review.
|
||||
|
||||
## Commit Message Convention
|
||||
|
||||
We recommend following [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
- `feat:` — new feature
|
||||
- `fix:` — bug fix
|
||||
- `docs:` — documentation only
|
||||
- `refactor:` — code change without feature or fix
|
||||
- `chore:` — build process or tooling changes
|
||||
|
||||
## Architecture & Coding Guidelines
|
||||
|
||||
Netcatty is organized around three layers:
|
||||
|
||||
- Domain logic in `domain/`
|
||||
- Application state in `application/state/`
|
||||
- UI components in `components/`
|
||||
|
||||
Keep side effects in application or infrastructure code, avoid direct
|
||||
`localStorage` or network calls from components, and update relevant
|
||||
documentation when behavior changes.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](./LICENSE).
|
||||
+54
-76
@@ -5,13 +5,13 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
|
||||
<strong>🔥 AI 搭載の SSH クライアント、SFTP ブラウザ & ターミナルマネージャー 🚀</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
|
||||
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,16 +40,45 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent — あなたの IT 運用 AI パートナー
|
||||
|
||||
> 🚀 **AI で日常の IT 運用作業を加速。** Catty Agent はサーバー環境を理解し、コマンドを実行し、自然な会話で複数ホストの複雑な操作をこなす内蔵 AI アシスタントです。
|
||||
|
||||
### 🔥 Catty Agent にできること
|
||||
|
||||
- 🚀 **自然言語でサーバー管理** — コマンドを暗記せず、必要なことを話しかけるだけ
|
||||
- 🔥 **リアルタイムサーバー診断** — 会話を通じてステータス確認、ログ調査、リソース監視
|
||||
- 🚀 **マルチホスト連携** — 複数サーバーのタスクを同時にコーディネート
|
||||
- 🔥 **インテリジェントなコンテキスト認識** — サーバー環境を理解し、的確な回答を提供
|
||||
- 🚀 **ワンステップで複雑な操作** — クラスター構築、サービスデプロイなど簡単な指示で実行
|
||||
|
||||
### 🎬 AI の動作デモ
|
||||
|
||||
#### 🔥 シングルホスト — インテリジェントなサーバー診断
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
|
||||
---
|
||||
|
||||
# 目次 <!-- omit in toc -->
|
||||
|
||||
- [🔥 Catty Agent — AI パートナー](#catty-agent)
|
||||
- [Netcatty とは](#netcatty-とは)
|
||||
- [なぜ Netcatty](#なぜ-netcatty)
|
||||
- [機能](#機能)
|
||||
- [デモ](#デモ)
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
@@ -71,9 +100,10 @@
|
||||
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
|
||||
|
||||
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
|
||||
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
|
||||
- **Netcatty は** デュアルペインのファイルブラウザを備えた強力な SFTP クライアント
|
||||
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
|
||||
- **Netcatty は** SSH、ローカルターミナル、Telnet、Mosh、シリアル接続をサポートします(利用可能な場合)
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh またはローカル/シリアルセッション経由でシェルに接続します
|
||||
|
||||
---
|
||||
|
||||
@@ -82,9 +112,9 @@
|
||||
|
||||
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
|
||||
|
||||
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
|
||||
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
|
||||
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
|
||||
- **ワークスペース中心** — 分割ペイン + タブ + セッション復元で常時使うワークフローに対応
|
||||
- **Vault の整理** — グリッド/リスト/ツリー表示、高速検索、ドラッグしやすいワークフロー
|
||||
- **本格的な SFTP** — 内蔵エディタ + ドラッグ&ドロップ + スムーズなファイル操作
|
||||
|
||||
---
|
||||
|
||||
@@ -109,43 +139,6 @@
|
||||
|
||||
---
|
||||
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
<a name="スクリーンショット"></a>
|
||||
# スクリーンショット
|
||||
|
||||
@@ -154,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||

|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
|
||||

|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||

|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
@@ -201,8 +190,6 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="はじめに"></a>
|
||||
# はじめに
|
||||
|
||||
@@ -220,6 +207,16 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
|
||||
|
||||
```bash
|
||||
nix run github:binaricat/Netcatty
|
||||
```
|
||||
|
||||
宣言型インストールには、Netcatty flake を input として追加し、NixOS または Home Manager のパッケージリストで `inputs.netcatty.packages.${pkgs.system}.default` を使用してください。
|
||||
|
||||
### 前提条件
|
||||
- Node.js 18+ と npm
|
||||
- macOS、Windows 10+、または Linux
|
||||
@@ -238,25 +235,6 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### プロジェクト構造
|
||||
|
||||
```
|
||||
├── App.tsx # メイン React アプリケーション
|
||||
├── components/ # React コンポーネント
|
||||
│ ├── Terminal.tsx # ターミナルコンポーネント
|
||||
│ ├── SftpView.tsx # SFTP ブラウザ
|
||||
│ ├── VaultView.tsx # ホスト管理
|
||||
│ ├── KeyManager.tsx # SSH キー管理
|
||||
│ └── ...
|
||||
├── application/ # 状態管理 & 国際化
|
||||
├── domain/ # ドメインモデル & ロジック
|
||||
├── infrastructure/ # サービス & アダプター
|
||||
├── electron/ # Electron メインプロセス
|
||||
│ ├── main.cjs # メインエントリ
|
||||
│ └── bridges/ # IPC ブリッジ
|
||||
└── public/ # 静的アセット & アイコン
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="ビルドとパッケージ"></a>
|
||||
|
||||
@@ -40,8 +40,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
|
||||
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,25 +62,13 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
|
||||
|
||||
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +78,6 @@ https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
- [Demos](#demos)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Main Window](#main-window)
|
||||
- [Vault Views](#vault-views)
|
||||
@@ -150,76 +136,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
- **Keyword highlighting** — customize highlight rules for terminal output
|
||||
|
||||
---
|
||||
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
|
||||
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
|
||||
|
||||
|
||||
|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
<a name="screenshots"></a>
|
||||
@@ -230,29 +146,27 @@ https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||

|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
|
||||

|
||||
|
||||
<a name="vault-views"></a>
|
||||
## Vault Views
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<a name="split-terminals"></a>
|
||||
## Split Terminals
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||

|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
@@ -294,6 +208,16 @@ Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatt
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
|
||||
|
||||
```bash
|
||||
nix run github:binaricat/Netcatty
|
||||
```
|
||||
|
||||
For declarative installs, add the Netcatty flake as an input and use `inputs.netcatty.packages.${pkgs.system}.default` in your NixOS or Home Manager package list.
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
- macOS, Windows 10+, or Linux
|
||||
@@ -312,25 +236,6 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
├── App.tsx # Main React application
|
||||
├── components/ # React components
|
||||
│ ├── Terminal.tsx # Terminal component
|
||||
│ ├── SftpView.tsx # SFTP browser
|
||||
│ ├── VaultView.tsx # Host management
|
||||
│ ├── KeyManager.tsx # SSH key management
|
||||
│ └── ...
|
||||
├── application/ # State management & i18n
|
||||
├── domain/ # Domain models & logic
|
||||
├── infrastructure/ # Services & adapters
|
||||
├── electron/ # Electron main process
|
||||
│ ├── main.cjs # Main entry
|
||||
│ └── bridges/ # IPC bridges
|
||||
└── public/ # Static assets & icons
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="build--package"></a>
|
||||
|
||||
+52
-73
@@ -5,13 +5,13 @@
|
||||
<h1 align="center">Netcatty</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
|
||||
<strong>🔥 AI 驱动的 SSH 客户端、SFTP 浏览器 & 终端管理器 🚀</strong><br/>
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
|
||||
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,16 +40,45 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/main-window-dark.png)
|
||||
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
|
||||
|
||||
---
|
||||
|
||||
<a name="catty-agent"></a>
|
||||
# 🔥 Catty Agent — 你的 IT 运维 AI 搭档
|
||||
|
||||
> 🚀 **用 AI 赋能你的日常 IT 运维工作。** Catty Agent 是内置的 AI 助手,能理解你的服务器环境、执行命令,并通过自然对话完成复杂的多主机操作。
|
||||
|
||||
### 🔥 Catty Agent 能做什么?
|
||||
|
||||
- 🚀 **自然语言管理服务器** —— 直接说出需求,不再需要记忆命令
|
||||
- 🔥 **实时服务器诊断** —— 通过对话检查状态、查看日志、监控资源
|
||||
- 🚀 **多主机协同** —— 跨多台服务器同时协调任务
|
||||
- 🔥 **智能上下文感知** —— 理解你的服务器环境,给出有针对性的回应
|
||||
- 🚀 **一键完成复杂操作** —— 搭集群、部署服务,一句话搞定
|
||||
|
||||
### 🎬 AI 实战演示
|
||||
|
||||
#### 🔥 单主机 —— 智能服务器诊断
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
|
||||
|
||||
---
|
||||
|
||||
# 目录 <!-- omit in toc -->
|
||||
|
||||
- [🔥 Catty Agent — AI 搭档](#catty-agent)
|
||||
- [Netcatty 是什么](#netcatty-是什么)
|
||||
- [为什么是 Netcatty](#为什么是-netcatty)
|
||||
- [功能特性](#功能特性)
|
||||
- [演示](#演示)
|
||||
- [界面截图](#界面截图)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
@@ -83,9 +112,9 @@
|
||||
|
||||
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
|
||||
|
||||
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
|
||||
- **以工作区为核心** —— 分屏 + 标签页 + 会话恢复,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合快速搜索与拖拽流程更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传/下载,文件操作更丝滑
|
||||
|
||||
---
|
||||
|
||||
@@ -110,43 +139,6 @@
|
||||
|
||||
---
|
||||
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
<a name="界面截图"></a>
|
||||
# 界面截图
|
||||
|
||||
@@ -155,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||

|
||||
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
|
||||
|
||||

|
||||
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||

|
||||
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||

|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
|
||||
---
|
||||
|
||||
@@ -219,6 +207,16 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### Nix / NixOS
|
||||
|
||||
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
|
||||
|
||||
```bash
|
||||
nix run github:binaricat/Netcatty
|
||||
```
|
||||
|
||||
声明式安装时,将 Netcatty flake 添加为输入,并在 NixOS 或 Home Manager 的软件包列表中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
|
||||
|
||||
### 前置条件
|
||||
- Node.js 18+ 和 npm
|
||||
- macOS、Windows 10+ 或 Linux
|
||||
@@ -237,25 +235,6 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
├── App.tsx # 主 React 应用
|
||||
├── components/ # React 组件
|
||||
│ ├── Terminal.tsx # 终端组件
|
||||
│ ├── SftpView.tsx # SFTP 浏览器
|
||||
│ ├── VaultView.tsx # 主机管理
|
||||
│ ├── KeyManager.tsx # SSH 密钥管理
|
||||
│ └── ...
|
||||
├── application/ # 状态管理 & 国际化
|
||||
├── domain/ # 领域模型 & 逻辑
|
||||
├── infrastructure/ # 服务 & 适配器
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.cjs # 主入口
|
||||
│ └── bridges/ # IPC 桥接
|
||||
└── public/ # 静态资源 & 图标
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<a name="构建与打包"></a>
|
||||
@@ -271,7 +250,7 @@ npm run pack
|
||||
# 为特定平台打包
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS 安装程序)
|
||||
npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
|
||||
import {
|
||||
flushQueuedTrayPanelConnectHostsImpl,
|
||||
handleConnectToHostImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
|
||||
|
||||
assert.equal(result, 'serial-session');
|
||||
});
|
||||
|
||||
test('tray panel connect request queues until the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: false,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, ['host-1']);
|
||||
assert.deepEqual(connectedHostIds, []);
|
||||
});
|
||||
|
||||
test('tray panel connect request runs immediately after the vault is initialized', () => {
|
||||
const queuedHostIds: string[] = [];
|
||||
const connectedHostIds: string[] = [];
|
||||
|
||||
handleTrayPanelConnectRequestImpl(
|
||||
() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
isVaultInitialized: true,
|
||||
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
|
||||
}),
|
||||
'host-1',
|
||||
);
|
||||
|
||||
assert.deepEqual(queuedHostIds, []);
|
||||
assert.deepEqual(connectedHostIds, ['host-1']);
|
||||
});
|
||||
|
||||
test('queued tray panel connects flush in order', () => {
|
||||
const connectedHostIds: string[] = [];
|
||||
let pendingHostIds = ['host-1', 'host-2'];
|
||||
|
||||
flushQueuedTrayPanelConnectHostsImpl(() => ({
|
||||
connectNow: (hostId: string) => connectedHostIds.push(hostId),
|
||||
pendingHostIds,
|
||||
setPendingHostIds: (nextHostIds: string[]) => {
|
||||
pendingHostIds = nextHostIds;
|
||||
},
|
||||
}));
|
||||
|
||||
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
|
||||
assert.deepEqual(pendingHostIds, []);
|
||||
});
|
||||
@@ -46,6 +46,42 @@ test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer wi
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
const localSession = sourceSession({
|
||||
hostLabel: "Local Terminal",
|
||||
hostname: "localhost",
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project with spaces ",
|
||||
});
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [localSession],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Local Terminal",
|
||||
sourceSession: localSession,
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
|
||||
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
|
||||
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
|
||||
{
|
||||
{
|
||||
const host = hosts.find((item) => item.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
}
|
||||
}
|
||||
|
||||
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
|
||||
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
|
||||
if (!hostId) return;
|
||||
if (!isVaultInitialized) {
|
||||
queueConnect(hostId);
|
||||
return;
|
||||
}
|
||||
connectNow(hostId);
|
||||
}
|
||||
|
||||
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
|
||||
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
|
||||
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
|
||||
const hostIds = [...pendingHostIds];
|
||||
setPendingHostIds([]);
|
||||
hostIds.forEach((hostId: string) => connectNow(hostId));
|
||||
}
|
||||
|
||||
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
|
||||
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
|
||||
{
|
||||
@@ -727,7 +745,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
}
|
||||
|
||||
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
|
||||
export function handleCreateLocalTerminalImpl(
|
||||
getCtx: AppContextGetter,
|
||||
shell?: { command: string; args?: string[]; name?: string; icon?: string },
|
||||
options?: { localStartDir?: string },
|
||||
) {
|
||||
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
@@ -742,6 +764,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
|
||||
shellArgs: resolved?.args,
|
||||
shellName,
|
||||
shellIcon,
|
||||
localStartDir: options?.localStartDir,
|
||||
});
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -315,7 +315,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
/>
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
hosts={terminalHosts}
|
||||
portForwardingRules={portForwardingRules}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -390,7 +390,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
updateNotes={updateNotes}
|
||||
@@ -401,6 +401,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
|
||||
@@ -113,8 +113,3 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
};
|
||||
});
|
||||
|
||||
const hostsForSystemMenu = hosts
|
||||
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
|
||||
.map((host: any) => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
group: host.group,
|
||||
pinned: host.pinned,
|
||||
lastConnectedAt: host.lastConnectedAt,
|
||||
protocol: host.protocol,
|
||||
}));
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
hosts: hostsForSystemMenu,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, sessions, portForwardingRules, workspaces]);
|
||||
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
|
||||
|
||||
// Quit guard: block app exit while any editor tab has unsaved changes.
|
||||
// Main process sends "app:query-dirty-editors"; we respond with the result.
|
||||
|
||||
@@ -321,6 +321,7 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
'settings.terminal.section.accessibility': 'Accessibility',
|
||||
'settings.terminal.section.behavior': 'Behavior',
|
||||
'settings.terminal.section.sidePanel': 'Side Panel',
|
||||
'settings.terminal.section.scrollback': 'Scrollback',
|
||||
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
|
||||
'settings.terminal.font.family': 'Font',
|
||||
@@ -368,6 +369,17 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.sidePanel.autoOpen': 'Open side panel with terminal',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': 'Automatically open a side panel when a terminal connects.',
|
||||
'settings.terminal.sidePanel.autoOpenPane': 'Default side panel',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': 'Scripts',
|
||||
'settings.terminal.sidePanel.pane.history': 'History',
|
||||
'settings.terminal.sidePanel.pane.theme': 'Theme',
|
||||
'settings.terminal.sidePanel.pane.system': 'System',
|
||||
'settings.terminal.sidePanel.pane.notes': 'Notes',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
'settings.terminal.behavior.rightClick': 'Right-click behavior',
|
||||
@@ -476,6 +488,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
|
||||
@@ -709,6 +723,12 @@ export const enCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
|
||||
|
||||
'vault.deleteConfirm.title': 'Delete "{name}"?',
|
||||
'vault.deleteConfirm.desc': 'This action cannot be undone.',
|
||||
'vault.deleteConfirm.packageDesc': 'This will remove the script package. Scripts inside it will stay saved and move out of the package.',
|
||||
'vault.deleteConfirm.noteGroupDesc': 'This will remove the folder. Notes inside it will stay saved and move out of the folder.',
|
||||
'vault.deleteConfirm.portForwardingDesc': 'This will delete the port forwarding rule.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
@@ -751,5 +771,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
'deepLink.ssh.invalid': 'Invalid ssh:// link',
|
||||
'deepLink.jms.invalid': 'Invalid JumpServer link',
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
|
||||
};
|
||||
@@ -9,7 +9,7 @@ export const enScriptsMessages = {
|
||||
'scripts.trigger.manual': 'Manual run',
|
||||
'scripts.trigger.onConnect': 'Run on connect',
|
||||
'scripts.trigger.onOutput': 'Run on output match',
|
||||
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.',
|
||||
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitForText or nct.screen.waitForRegex in script code.',
|
||||
'scripts.actions.save': 'Save',
|
||||
'scripts.actions.runNow': 'Run now',
|
||||
'scripts.actions.openEditor': 'Open editor',
|
||||
@@ -69,6 +69,11 @@ export const enScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
|
||||
'scripts.dialog.title': 'Script',
|
||||
'scripts.dialog.ok': 'OK',
|
||||
'scripts.dialog.required': 'Required',
|
||||
'scripts.dialog.numberInvalid': 'Enter a valid number',
|
||||
'scripts.dialog.numberMin': 'Must be at least {min}',
|
||||
'scripts.dialog.numberMax': 'Must be at most {max}',
|
||||
'scripts.dialog.numberStep': 'Must use increments of {step}',
|
||||
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
|
||||
'scripts.dialog.retry': 'Retry',
|
||||
'scripts.dialog.skip': 'Skip',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.overview': 'Overview',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
'systemManager.overview.memory': 'Memory',
|
||||
'systemManager.overview.disk': 'Disk',
|
||||
'systemManager.overview.network': 'Network',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} cores',
|
||||
'systemManager.overview.load': 'Load',
|
||||
'systemManager.overview.uptime': 'Uptime',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}m',
|
||||
'systemManager.overview.system': 'System',
|
||||
'systemManager.overview.kernel': 'Kernel',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Latency',
|
||||
'systemManager.overview.cpuCores': 'CPU cores',
|
||||
'systemManager.overview.disks': 'Disks',
|
||||
'systemManager.overview.interfaces': 'Network interfaces',
|
||||
'systemManager.overview.topProcesses': 'Top memory processes',
|
||||
'systemManager.overview.noData': 'No data',
|
||||
'systemManager.overview.noDisks': 'No disk data',
|
||||
'systemManager.overview.noInterfaces': 'No interface data',
|
||||
'systemManager.overview.noTopProcesses': 'No process data',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
|
||||
@@ -155,6 +155,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
|
||||
'terminal.auth.certificate': 'Certificate',
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
@@ -166,6 +168,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
'terminal.progress.startOver': 'Start over',
|
||||
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
|
||||
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
|
||||
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
@@ -673,6 +678,10 @@ export const enTerminalMessages: Messages = {
|
||||
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
|
||||
'snippets.field.multiLineRunMode': 'Multi-line run',
|
||||
'snippets.field.multiLineRunMode.paste': 'Send all at once',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Send line by line',
|
||||
'snippets.field.multiLineRunModeHint': 'Use line by line for prompt-based logins or device macros.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
|
||||
@@ -385,6 +385,9 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH links',
|
||||
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
|
||||
'settings.jmsDeepLink.title': 'JumpServer Links',
|
||||
'settings.jmsDeepLink.enable': 'Handle jms:// links',
|
||||
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
@@ -678,12 +681,17 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.keychainIdentity': 'Keychain identity',
|
||||
'hostDetails.proxyPanel.manualCredentials': 'Manual credentials',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Missing keychain identity',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'Proxy identity needs a username and password',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'Proxy identity password cannot be read on this device',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
@@ -711,10 +719,15 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.startupCommand.placeholder': 'Command to run on connect (e.g., cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'This command will be executed automatically after SSH connection is established.',
|
||||
'hostDetails.startupCommand.runMode': 'Multi-line run',
|
||||
'hostDetails.startupCommand.runMode.help': 'Choose how multi-line startup commands are sent after connection.',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': 'Send line by line',
|
||||
'hostDetails.startupCommand.runMode.paste': 'Send all at once',
|
||||
'hostDetails.otherProtocols': 'Other Protocols',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': 'port',
|
||||
'hostDetails.telnet.credentials': 'Credentials',
|
||||
'hostDetails.telnet.identity.placeholder': 'Manual identity',
|
||||
'hostDetails.telnet.username': 'Telnet Username',
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
|
||||
@@ -725,11 +725,19 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
|
||||
|
||||
'vault.deleteConfirm.title': 'Удалить "{name}"?',
|
||||
'vault.deleteConfirm.desc': 'Это действие нельзя отменить.',
|
||||
'vault.deleteConfirm.packageDesc': 'Пакет скриптов будет удалён. Скрипты внутри него сохранятся и будут вынесены из пакета.',
|
||||
'vault.deleteConfirm.noteGroupDesc': 'Папка будет удалена. Заметки внутри неё сохранятся и будут вынесены из папки.',
|
||||
'vault.deleteConfirm.portForwardingDesc': 'Правило проброса портов будет удалено.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Снять управление',
|
||||
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
|
||||
|
||||
'vault.hosts.header.entries': 'Записей: {count}',
|
||||
'vault.hosts.header.live': 'Активных: {count}',
|
||||
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
|
||||
'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer',
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.overview': 'Обзор',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
'systemManager.overview.memory': 'Память',
|
||||
'systemManager.overview.disk': 'Диск',
|
||||
'systemManager.overview.network': 'Сеть',
|
||||
'systemManager.overview.rx': 'RX',
|
||||
'systemManager.overview.tx': 'TX',
|
||||
'systemManager.overview.cores': '{{count}} ядер',
|
||||
'systemManager.overview.load': 'Нагрузка',
|
||||
'systemManager.overview.uptime': 'Время работы',
|
||||
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}} мин',
|
||||
'systemManager.overview.system': 'Система',
|
||||
'systemManager.overview.kernel': 'Ядро',
|
||||
'systemManager.overview.swap': 'Swap',
|
||||
'systemManager.overview.latency': 'Задержка',
|
||||
'systemManager.overview.cpuCores': 'Ядра CPU',
|
||||
'systemManager.overview.disks': 'Диски',
|
||||
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
|
||||
'systemManager.overview.topProcesses': 'Процессы по памяти',
|
||||
'systemManager.overview.noData': 'Нет данных',
|
||||
'systemManager.overview.noDisks': 'Нет данных дисков',
|
||||
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
|
||||
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
|
||||
@@ -175,6 +175,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
|
||||
'terminal.auth.certificate': 'Сертификат',
|
||||
'terminal.auth.selectKey': 'Выбрать ключ',
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
@@ -186,6 +188,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.progress.disconnected': 'Отключено',
|
||||
'terminal.progress.cancelling': 'Отмена...',
|
||||
'terminal.progress.startOver': 'Начать заново',
|
||||
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
|
||||
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
|
||||
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
|
||||
'terminal.connection.showLogs': 'Показать журналы',
|
||||
@@ -691,6 +694,10 @@ export const ruTerminalMessages: Messages = {
|
||||
'snippets.renameDialog.error.invalidChars': 'Имя пакета может содержать только буквы, цифры, дефисы и подчёркивания',
|
||||
|
||||
'snippets.field.noAutoRun': 'Только вставить (не выполнять автоматически)',
|
||||
'snippets.field.multiLineRunMode': 'Многострочный запуск',
|
||||
'snippets.field.multiLineRunMode.paste': 'Отправить сразу',
|
||||
'snippets.field.multiLineRunMode.lineDelay': 'Отправлять построчно',
|
||||
'snippets.field.multiLineRunModeHint': 'Построчный режим подходит для логинов с подсказками и макросов устройств.',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Сочетание клавиш',
|
||||
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
|
||||
|
||||
@@ -420,6 +420,9 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH-ссылки',
|
||||
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
|
||||
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
|
||||
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
|
||||
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
|
||||
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
|
||||
@@ -710,12 +713,17 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
|
||||
'hostDetails.proxyPanel.identities': 'Идентификаторы',
|
||||
'hostDetails.proxyPanel.keychainIdentity': 'Идентификатор из связки ключей',
|
||||
'hostDetails.proxyPanel.manualCredentials': 'Ввести вручную',
|
||||
'hostDetails.proxyPanel.remove': 'Удалить прокси',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
|
||||
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
|
||||
'hostDetails.proxyPanel.missing': 'Отсутствует',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
|
||||
'hostDetails.proxyPanel.missingIdentity': 'Идентификатор из связки ключей отсутствует',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': 'Для идентификатора прокси нужны имя пользователя и пароль',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': 'Пароль идентификатора прокси нельзя прочитать на этом устройстве',
|
||||
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
|
||||
'hostDetails.envVars': 'Переменные окружения',
|
||||
'hostDetails.envVars.add': 'Добавить переменную окружения',
|
||||
@@ -742,10 +750,15 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.startupCommand.placeholder': 'Команда для запуска при подключении (например, cd /app && ls)',
|
||||
'hostDetails.startupCommand.help':
|
||||
'This command will be executed automatically after SSH connection is established.',
|
||||
'hostDetails.startupCommand.runMode': 'Многострочный запуск',
|
||||
'hostDetails.startupCommand.runMode.help': 'Выберите, как отправлять многострочные команды запуска после подключения.',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': 'Отправлять построчно',
|
||||
'hostDetails.startupCommand.runMode.paste': 'Отправить сразу',
|
||||
'hostDetails.otherProtocols': 'Другие протоколы',
|
||||
'hostDetails.telnetOn': 'Telnet на',
|
||||
'hostDetails.port': 'порт',
|
||||
'hostDetails.telnet.credentials': 'Учётные данные',
|
||||
'hostDetails.telnet.identity.placeholder': 'Ручной идентификатор',
|
||||
'hostDetails.telnet.username': 'Имя пользователя Telnet',
|
||||
'hostDetails.telnet.password': 'Пароль Telnet',
|
||||
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
|
||||
|
||||
@@ -69,6 +69,18 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.tab.system': '系统',
|
||||
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
|
||||
'settings.terminal.section.sidePanel': '侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpen': '打开终端时展开侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': '终端连接成功后自动打开一个侧边栏。',
|
||||
'settings.terminal.sidePanel.autoOpenPane': '默认侧边栏',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': '脚本',
|
||||
'settings.terminal.sidePanel.pane.history': '历史',
|
||||
'settings.terminal.sidePanel.pane.theme': '主题',
|
||||
'settings.terminal.sidePanel.pane.system': '系统',
|
||||
'settings.terminal.sidePanel.pane.notes': '笔记',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系统',
|
||||
@@ -438,6 +450,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
|
||||
|
||||
'vault.deleteConfirm.title': '删除“{name}”?',
|
||||
'vault.deleteConfirm.desc': '此操作不可撤销。',
|
||||
'vault.deleteConfirm.packageDesc': '这会删除脚本包,包内脚本会保留,并移出该脚本包。',
|
||||
'vault.deleteConfirm.noteGroupDesc': '这会删除文件夹,文件夹内笔记会保留,并移出该文件夹。',
|
||||
'vault.deleteConfirm.portForwardingDesc': '这会删除该端口转发规则。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
@@ -480,6 +498,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
|
||||
'deepLink.jms.invalid': '无效的 JumpServer 链接',
|
||||
'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
|
||||
@@ -9,7 +9,7 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.trigger.manual': '手动运行',
|
||||
'scripts.trigger.onConnect': '连接时运行',
|
||||
'scripts.trigger.onOutput': '输出匹配时运行',
|
||||
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。',
|
||||
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待普通文本用 nct.screen.waitForText,正则用 nct.screen.waitForRegex。',
|
||||
'scripts.actions.save': '保存',
|
||||
'scripts.actions.runNow': '立即运行',
|
||||
'scripts.actions.openEditor': '弹窗编辑',
|
||||
@@ -69,6 +69,11 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
|
||||
'scripts.dialog.title': '脚本',
|
||||
'scripts.dialog.ok': '确定',
|
||||
'scripts.dialog.required': '必填',
|
||||
'scripts.dialog.numberInvalid': '请输入有效数字',
|
||||
'scripts.dialog.numberMin': '不能小于 {min}',
|
||||
'scripts.dialog.numberMax': '不能大于 {max}',
|
||||
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待超时',
|
||||
'scripts.dialog.retry': '重试',
|
||||
'scripts.dialog.skip': '跳过',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': '没有活动的终端会话。',
|
||||
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.overview': '概览',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
'systemManager.overview.memory': '内存',
|
||||
'systemManager.overview.disk': '磁盘',
|
||||
'systemManager.overview.network': '网络',
|
||||
'systemManager.overview.rx': '下行',
|
||||
'systemManager.overview.tx': '上行',
|
||||
'systemManager.overview.cores': '{{count}} 核',
|
||||
'systemManager.overview.load': '负载',
|
||||
'systemManager.overview.uptime': '运行时间',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
|
||||
'systemManager.overview.system': '系统',
|
||||
'systemManager.overview.kernel': '内核',
|
||||
'systemManager.overview.swap': '交换区',
|
||||
'systemManager.overview.latency': '延迟',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁盘',
|
||||
'systemManager.overview.interfaces': '网络接口',
|
||||
'systemManager.overview.topProcesses': '内存占用最高进程',
|
||||
'systemManager.overview.noData': '暂无数据',
|
||||
'systemManager.overview.noDisks': '暂无磁盘数据',
|
||||
'systemManager.overview.noInterfaces': '暂无网卡数据',
|
||||
'systemManager.overview.noTopProcesses': '暂无进程数据',
|
||||
|
||||
'systemManager.processes.search': '搜索进程…',
|
||||
'systemManager.processes.command': '命令',
|
||||
'systemManager.processes.user': '用户',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
|
||||
'terminal.toolbar.timestampsEnable': '显示时间戳',
|
||||
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
|
||||
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
@@ -104,6 +107,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH 链接',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
|
||||
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 链接',
|
||||
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
|
||||
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
@@ -332,6 +338,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
|
||||
@@ -462,12 +470,17 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.keychainIdentity': '钥匙串身份',
|
||||
'hostDetails.proxyPanel.manualCredentials': '手动凭据',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已保存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.missingIdentity': '钥匙串身份不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要用户名和密码',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此设备无法读取代理身份密码',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
@@ -695,6 +708,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
|
||||
'snippets.field.multiLineRunMode': '多行运行方式',
|
||||
'snippets.field.multiLineRunMode.paste': '一次性发送',
|
||||
'snippets.field.multiLineRunMode.lineDelay': '逐行发送',
|
||||
'snippets.field.multiLineRunModeHint': '登录提示、网络设备宏等场景可用逐行发送。',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
|
||||
@@ -248,10 +248,15 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.startupCommand': '启动命令',
|
||||
'hostDetails.startupCommand.placeholder': '连接后执行的命令(例如:cd /app && ls)',
|
||||
'hostDetails.startupCommand.help': 'SSH 连接建立后将自动执行该命令。',
|
||||
'hostDetails.startupCommand.runMode': '多行运行方式',
|
||||
'hostDetails.startupCommand.runMode.help': '选择连接后如何发送多行启动命令。',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': '逐行发送',
|
||||
'hostDetails.startupCommand.runMode.paste': '一次性发送',
|
||||
'hostDetails.otherProtocols': '其他协议',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '端口',
|
||||
'hostDetails.telnet.credentials': '凭据',
|
||||
'hostDetails.telnet.identity.placeholder': '手动身份',
|
||||
'hostDetails.telnet.username': 'Telnet 用户名',
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
@@ -432,6 +437,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
|
||||
'terminal.auth.certificate': '证书',
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
|
||||
@@ -69,6 +69,18 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.tab.system': '系統',
|
||||
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
|
||||
'settings.terminal.section.sidePanel': '側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpen': '開啟終端時展開側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpen.desc': '終端連線成功後自動開啟一個側邊欄。',
|
||||
'settings.terminal.sidePanel.autoOpenPane': '預設側邊欄',
|
||||
'settings.terminal.sidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
|
||||
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
|
||||
'settings.terminal.sidePanel.pane.scripts': '指令碼',
|
||||
'settings.terminal.sidePanel.pane.history': '歷史',
|
||||
'settings.terminal.sidePanel.pane.theme': '主題',
|
||||
'settings.terminal.sidePanel.pane.system': '系統',
|
||||
'settings.terminal.sidePanel.pane.notes': '筆記',
|
||||
'settings.terminal.sidePanel.pane.ai': 'AI',
|
||||
|
||||
// Settings > System
|
||||
'settings.system.title': '系統',
|
||||
@@ -438,6 +450,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
|
||||
|
||||
'vault.deleteConfirm.title': '刪除「{name}」?',
|
||||
'vault.deleteConfirm.desc': '此操作不可復原。',
|
||||
'vault.deleteConfirm.packageDesc': '這會刪除程式碼包,包內程式碼片段會保留,並移出該程式碼包。',
|
||||
'vault.deleteConfirm.noteGroupDesc': '這會刪除資料夾,資料夾內筆記會保留,並移出該資料夾。',
|
||||
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消託管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消託管分組',
|
||||
|
||||
@@ -480,6 +498,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
|
||||
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
|
||||
'deepLink.jms.invalid': '無效的 JumpServer 連結',
|
||||
'deepLink.jms.unsupported': '不支援的 JumpServer 協定:{{protocol}}',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '新增資料到你的 Vault',
|
||||
|
||||
@@ -9,7 +9,7 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.trigger.manual': '手動執行',
|
||||
'scripts.trigger.onConnect': '連線時執行',
|
||||
'scripts.trigger.onOutput': '輸出比對時執行',
|
||||
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待請用 nct.screen.waitFor。',
|
||||
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待一般文字用 nct.screen.waitForText,正規表示式用 nct.screen.waitForRegex。',
|
||||
'scripts.actions.save': '儲存',
|
||||
'scripts.actions.runNow': '立即執行',
|
||||
'scripts.actions.openEditor': '彈出視窗編輯',
|
||||
@@ -69,6 +69,11 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
|
||||
'scripts.dialog.title': '指令碼',
|
||||
'scripts.dialog.ok': '確定',
|
||||
'scripts.dialog.required': '必填',
|
||||
'scripts.dialog.numberInvalid': '請輸入有效數字',
|
||||
'scripts.dialog.numberMin': '不能小於 {min}',
|
||||
'scripts.dialog.numberMax': '不能大於 {max}',
|
||||
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
|
||||
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
|
||||
'scripts.dialog.retry': '重試',
|
||||
'scripts.dialog.skip': '跳過',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.noSession': '沒有使用中的終端工作階段。',
|
||||
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
|
||||
'systemManager.empty': '暫無資料。',
|
||||
'systemManager.tabs.overview': '概覽',
|
||||
'systemManager.tabs.processes': '程序',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
@@ -21,8 +22,35 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
'systemManager.overview.memory': '記憶體',
|
||||
'systemManager.overview.disk': '磁碟',
|
||||
'systemManager.overview.network': '網路',
|
||||
'systemManager.overview.rx': '下行',
|
||||
'systemManager.overview.tx': '上行',
|
||||
'systemManager.overview.cores': '{{count}} 核心',
|
||||
'systemManager.overview.load': '負載',
|
||||
'systemManager.overview.uptime': '執行時間',
|
||||
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
|
||||
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
|
||||
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
|
||||
'systemManager.overview.system': '系統',
|
||||
'systemManager.overview.kernel': '核心',
|
||||
'systemManager.overview.swap': '交換區',
|
||||
'systemManager.overview.latency': '延遲',
|
||||
'systemManager.overview.cpuCores': 'CPU 核心',
|
||||
'systemManager.overview.disks': '磁碟',
|
||||
'systemManager.overview.interfaces': '網路介面',
|
||||
'systemManager.overview.topProcesses': '記憶體占用最高程序',
|
||||
'systemManager.overview.noData': '暫無資料',
|
||||
'systemManager.overview.noDisks': '暫無磁碟資料',
|
||||
'systemManager.overview.noInterfaces': '暫無網卡資料',
|
||||
'systemManager.overview.noTopProcesses': '暫無程序資料',
|
||||
|
||||
'systemManager.processes.search': '搜尋程序…',
|
||||
'systemManager.processes.command': '指令',
|
||||
'systemManager.processes.user': '使用者',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
|
||||
'terminal.toolbar.timestampsEnable': '顯示時間戳',
|
||||
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
|
||||
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
|
||||
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
|
||||
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
|
||||
@@ -104,6 +107,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.sshDeepLink.title': 'SSH 連結',
|
||||
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
|
||||
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
|
||||
'settings.jmsDeepLink.title': 'JumpServer 連結',
|
||||
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
|
||||
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自動同步到遠端',
|
||||
@@ -332,6 +338,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
|
||||
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
|
||||
@@ -462,12 +470,17 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
|
||||
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自訂代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
|
||||
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
|
||||
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
|
||||
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
|
||||
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
|
||||
'hostDetails.envVars.title': '環境變數',
|
||||
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
|
||||
@@ -695,6 +708,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'snippets.renameDialog.error.invalidChars': '程式碼包名稱只能包含字母、數字、連字元和下劃線',
|
||||
|
||||
'snippets.field.noAutoRun': '僅貼上(不自動執行)',
|
||||
'snippets.field.multiLineRunMode': '多行執行方式',
|
||||
'snippets.field.multiLineRunMode.paste': '一次性傳送',
|
||||
'snippets.field.multiLineRunMode.lineDelay': '逐行傳送',
|
||||
'snippets.field.multiLineRunModeHint': '登入提示、網路設備巨集等情境可用逐行傳送。',
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷鍵',
|
||||
'snippets.shortkey.placeholder': '點選設定快捷鍵',
|
||||
|
||||
@@ -248,10 +248,15 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.startupCommand': '啟動指令',
|
||||
'hostDetails.startupCommand.placeholder': '連線後執行的指令(例如:cd /app && ls)',
|
||||
'hostDetails.startupCommand.help': 'SSH 連線建立後將自動執行該指令。',
|
||||
'hostDetails.startupCommand.runMode': '多行執行方式',
|
||||
'hostDetails.startupCommand.runMode.help': '選擇連線後如何傳送多行啟動指令。',
|
||||
'hostDetails.startupCommand.runMode.lineDelay': '逐行傳送',
|
||||
'hostDetails.startupCommand.runMode.paste': '一次性傳送',
|
||||
'hostDetails.otherProtocols': '其他協定',
|
||||
'hostDetails.telnetOn': 'Telnet on',
|
||||
'hostDetails.port': '埠',
|
||||
'hostDetails.telnet.credentials': '憑證',
|
||||
'hostDetails.telnet.identity.placeholder': '手動身份',
|
||||
'hostDetails.telnet.username': 'Telnet 使用者名稱',
|
||||
'hostDetails.telnet.password': 'Telnet 密碼',
|
||||
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
|
||||
@@ -432,6 +437,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
|
||||
'terminal.auth.certificate': '憑證',
|
||||
'terminal.auth.selectKey': '選擇金鑰',
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
@@ -12,3 +13,42 @@ test('editor tab helper detects editor top-tab ids', () => {
|
||||
assert.equal(isEditorTabId('editor:file-1'), true);
|
||||
assert.equal(isEditorTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('active tab changes do not start terminal layout suppression', async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window ??= globalThis;
|
||||
|
||||
while (terminalLayoutSuppressStore.getActive()) {
|
||||
terminalLayoutSuppressStore.end();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let activeNotifyCount = 0;
|
||||
let suppressNotifyCount = 0;
|
||||
const unsubscribeActiveTab = activeTabStore.subscribe(() => {
|
||||
activeNotifyCount += 1;
|
||||
});
|
||||
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
|
||||
suppressNotifyCount += 1;
|
||||
});
|
||||
|
||||
try {
|
||||
activeTabStore.setActiveTabId(`no-suppress-test-${Date.now()}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
assert.equal(activeNotifyCount, 1);
|
||||
assert.equal(suppressNotifyCount, 0);
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
} finally {
|
||||
unsubscribeActiveTab();
|
||||
unsubscribeSuppress();
|
||||
if (previousWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
while (terminalLayoutSuppressStore.getActive()) {
|
||||
terminalLayoutSuppressStore.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
type SyncListener = (activeTabId: string) => void;
|
||||
@@ -39,20 +37,11 @@ class ActiveTabStore {
|
||||
|
||||
setActiveTabId = (id: string) => {
|
||||
if (this.activeTabId !== id) {
|
||||
terminalLayoutSuppressStore.begin();
|
||||
this.activeTabId = id;
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
// "setState during render" if called from a render phase.
|
||||
this.scheduleNotify();
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
|
||||
schedule(() => {
|
||||
schedule(() => {
|
||||
terminalLayoutSuppressStore.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,11 +67,6 @@ export const useActiveTabId = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to get setter - never causes re-render
|
||||
export const useSetActiveTabId = () => {
|
||||
return activeTabStore.setActiveTabId;
|
||||
};
|
||||
|
||||
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
|
||||
export const useIsTabActive = (tabId: string) => {
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
|
||||
|
||||
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/** Get all themes (built-in + custom) */
|
||||
export const useAllThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getAllThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get custom themes only */
|
||||
export const useCustomThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Get theme by ID (built-in or custom) with fallback */
|
||||
export const useThemeById = (id: string): TerminalTheme => {
|
||||
const allThemes = useAllThemes();
|
||||
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
|
||||
};
|
||||
|
||||
/** Theme mutation actions */
|
||||
export const useCustomThemeActions = () => {
|
||||
const addTheme = useCallback((theme: TerminalTheme) => {
|
||||
|
||||
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font loading state
|
||||
*/
|
||||
export const useFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
fontStore.subscribe,
|
||||
fontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get font by ID with fallback - useful for components that need a specific font
|
||||
*/
|
||||
export const useFontById = (fontId: string): TerminalFont => {
|
||||
const fonts = useAvailableFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize fonts eagerly (call at app startup)
|
||||
*/
|
||||
|
||||
@@ -157,7 +157,11 @@ export async function runConnectScriptsSequential(params: {
|
||||
export async function runSnippetOrScript(params: {
|
||||
snippet: Snippet;
|
||||
sessionId: string;
|
||||
runSnippetText: (command: string, noAutoRun?: boolean) => void;
|
||||
runSnippetText: (
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
|
||||
) => void;
|
||||
command: string;
|
||||
}) {
|
||||
if (isScriptSnippet(params.snippet)) {
|
||||
@@ -167,7 +171,9 @@ export async function runSnippetOrScript(params: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
params.runSnippetText(params.command, params.snippet.noAutoRun);
|
||||
params.runSnippetText(params.command, params.snippet.noAutoRun, {
|
||||
multiLineRunMode: params.snippet.multiLineRunMode,
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface LocalTerminalOptions {
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
localStartDir?: string;
|
||||
}
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
@@ -24,6 +25,7 @@ export const createLocalTerminalSession = (
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
localStartDir: options?.localStartDir,
|
||||
});
|
||||
|
||||
export const createSerialTerminalSession = (
|
||||
@@ -86,5 +88,7 @@ export const createHostTerminalSession = (
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
...(host.ephemeral ? { ephemeralHost: true } : {}),
|
||||
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
@@ -39,6 +40,8 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -49,6 +52,7 @@ import {
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -70,6 +74,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -82,6 +87,8 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -108,6 +115,7 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -120,6 +128,8 @@ export function useSettingsIpcSync({
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
@@ -211,6 +221,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setSshDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
|
||||
setJmsDeepLinkEnabledState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -254,6 +267,12 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
|
||||
setTerminalSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
|
||||
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
|
||||
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -291,11 +310,14 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
|
||||
@@ -92,6 +92,7 @@ export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
|
||||
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
|
||||
STORAGE_KEY_RESTORE_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
@@ -58,6 +61,7 @@ import {
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
interface UseSettingsStorageSyncParams {
|
||||
enabled?: boolean;
|
||||
@@ -85,6 +89,8 @@ interface UseSettingsStorageSyncParams {
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
disableTerminalFontZoom: boolean;
|
||||
restorePreviousSession: boolean;
|
||||
@@ -96,6 +102,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
sshDeepLinkEnabled: boolean;
|
||||
jmsDeepLinkEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
@@ -126,6 +133,8 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
|
||||
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -137,6 +146,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
@@ -154,8 +164,8 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -163,8 +173,8 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -177,8 +187,8 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
@@ -187,8 +197,8 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
|
||||
@@ -358,6 +368,12 @@ export function useSettingsStorageSync({
|
||||
setSshDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.jmsDeepLinkEnabled) {
|
||||
setJmsDeepLinkEnabledState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -408,6 +424,17 @@ export function useSettingsStorageSync({
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.terminalSidePanelAutoOpen) {
|
||||
setTerminalSidePanelAutoOpenState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
|
||||
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.terminalSidePanelAutoOpenTab) {
|
||||
setTerminalSidePanelAutoOpenTabState(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
@@ -496,6 +523,7 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDeepLinkEnabledState,
|
||||
setJmsDeepLinkEnabledState,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -507,6 +535,8 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
|
||||
|
||||
const commonArgs = {
|
||||
terminalThemeId: "netcatty-dark",
|
||||
customThemes: [],
|
||||
resolvedTheme: "dark" as const,
|
||||
lightUiThemeId: "snow",
|
||||
darkUiThemeId: "github",
|
||||
accentMode: "theme" as const,
|
||||
customAccent: "",
|
||||
};
|
||||
|
||||
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
pendingFollowAppTerminalThemeId: "system-flexoki-light",
|
||||
resolvedTheme: "dark",
|
||||
lightUiThemeId: "flexoki",
|
||||
darkUiThemeId: "github",
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-flexoki-light");
|
||||
});
|
||||
|
||||
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: true,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "system-github-dark");
|
||||
});
|
||||
|
||||
test("manual terminal theme uses the active per-mode pick", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "dracula",
|
||||
terminalThemeLightId: "solarized-light",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
|
||||
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
|
||||
const theme = resolveCurrentTerminalTheme({
|
||||
...commonArgs,
|
||||
terminalThemeId: "dracula",
|
||||
followAppTerminalTheme: false,
|
||||
terminalThemeDarkId: "auto",
|
||||
terminalThemeLightId: "auto",
|
||||
});
|
||||
|
||||
assert.equal(theme.id, "dracula");
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveFollowAppTerminalThemeId,
|
||||
resolveManualTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
|
||||
interface ResolveCurrentTerminalThemeParams {
|
||||
terminalThemeId: string;
|
||||
terminalThemeDarkId: string;
|
||||
terminalThemeLightId: string;
|
||||
customThemes: TerminalTheme[];
|
||||
followAppTerminalTheme: boolean;
|
||||
pendingFollowAppTerminalThemeId?: string | null;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
lightUiThemeId: string;
|
||||
darkUiThemeId: string;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
}
|
||||
|
||||
export function resolveCurrentTerminalTheme({
|
||||
terminalThemeId,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
customThemes,
|
||||
followAppTerminalTheme,
|
||||
pendingFollowAppTerminalThemeId = null,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
accentMode,
|
||||
customAccent,
|
||||
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
|
||||
if (followAppTerminalTheme) {
|
||||
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const followed = getBuiltinTerminalThemeById(followedId)
|
||||
|| customThemes.find(t => t.id === followedId);
|
||||
if (followed) {
|
||||
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
|
||||
}
|
||||
}
|
||||
const manualThemeId = resolveManualTerminalThemeId({
|
||||
resolvedTheme,
|
||||
terminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
fallbackThemeId: terminalThemeId,
|
||||
});
|
||||
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|
||||
|| customThemes.find(t => t.id === manualThemeId)
|
||||
|| getBuiltinTerminalThemeById(terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -52,6 +52,129 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing proxy identities", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Proxy identity for "Host" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects incomplete proxy identities", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Host" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects proxy identities with blank usernames even when passwords are encrypted", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: " ",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Host" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing proxy identities on jump hosts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Proxy identity for "Jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy identity for "Jump" is incomplete/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
@@ -77,6 +200,130 @@ test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials resolves proxy credentials from a selected identity", () => {
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: identity.id,
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.proxy, {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable proxy identity passwords", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy credentials cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials resolves jump host proxy credentials from a selected identity", () => {
|
||||
const identity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "jump-proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: identity.id,
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
});
|
||||
|
||||
assert.deepEqual(credentials.jumpHosts?.[0]?.proxy, {
|
||||
type: "socks5",
|
||||
host: "jump-proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects undecryptable jump host proxy identity passwords", () => {
|
||||
const jumpHost = host({
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
/Proxy credentials for jump host "Jump" cannot be decrypted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
|
||||
const key: SSHKey = {
|
||||
id: "key-1",
|
||||
|
||||
@@ -3,7 +3,15 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
findMissingProxyIdentityId,
|
||||
formatIncompleteProxyIdentityMessage,
|
||||
formatMissingProxyIdentityMessage,
|
||||
hasUnreadableProxyCredential,
|
||||
hasUsableProxyConfig,
|
||||
resolveProxyConfigAuth,
|
||||
} from "../../../domain/proxyProfiles";
|
||||
|
||||
// Fallback used when no global TerminalSettings are wired through (older
|
||||
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
|
||||
@@ -34,19 +42,18 @@ export const buildSftpHostCredentials = ({
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
if (findMissingProxyIdentityId(host.proxyConfig, identities)) {
|
||||
throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname));
|
||||
}
|
||||
if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname));
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
command: host.proxyConfig.command,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
? resolveProxyConfigAuth(host.proxyConfig, identities)
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
@@ -58,6 +65,12 @@ export const buildSftpHostCredentials = ({
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
@@ -80,9 +93,7 @@ export const buildSftpHostCredentials = ({
|
||||
hasUsableProxyConfig(jumpHost.proxyConfig);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
hasUnreadableProxyCredential(jumpHost.proxyConfig, identities)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
@@ -110,14 +121,7 @@ export const buildSftpHostCredentials = ({
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
command: jumpHost.proxyConfig.command,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
|
||||
: undefined,
|
||||
identityFilePaths: jumpKeyAuth.identityFilePaths,
|
||||
keepaliveInterval: hopKeepalive.interval,
|
||||
@@ -130,7 +134,7 @@ export const buildSftpHostCredentials = ({
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted";
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
|
||||
async (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: { force?: boolean; tabId?: string },
|
||||
options?: SftpNavigateOptions,
|
||||
): Promise<SftpNavigateResult> => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
// When tabId is specified, target that specific tab instead of the active one.
|
||||
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
if (options?.shouldApply?.() === false) {
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
|
||||
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
|
||||
const previousFiles = confirmed.files;
|
||||
const previousSelection = confirmed.selectedFiles;
|
||||
const previousFilter = confirmed.filter;
|
||||
const previousError = pane.error;
|
||||
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
|
||||
const getTargetPane = () => {
|
||||
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
|
||||
};
|
||||
const shouldApplyNavigationState = () => (
|
||||
getTargetPane()?.connection?.id === connectionId
|
||||
&& (options?.shouldApply?.() ?? true)
|
||||
);
|
||||
const restoreConfirmedStateIfCurrent = () => {
|
||||
if (!isTargetRequestCurrent()) return;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
|
||||
return {
|
||||
...prev,
|
||||
connection: { ...prev.connection, currentPath: previousPath },
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: previousFilter,
|
||||
error: previousError,
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
tabNavSeqRef.current.set(targetTabId, requestId);
|
||||
// Keep existing files visible during loading — the loading overlay
|
||||
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||||
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
// For background tabs (explicit tabId), update that tab directly
|
||||
// instead of handleSessionError which targets the active tab.
|
||||
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
|
||||
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
sftpSessionsRef.current.delete(pane.connection.id);
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
if (options?.tabId) {
|
||||
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
// Side-level sequence was bumped by another tab's navigation or
|
||||
// a connect/disconnect. Check if THIS tab's request is still current.
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
if (!isTargetRequestCurrent()) {
|
||||
// This tab also has a newer navigation — drop completely.
|
||||
return "aborted";
|
||||
return "superseded";
|
||||
}
|
||||
// Side was superseded by another tab, but this tab's request is
|
||||
// still current. The fetched files are valid — fall through to
|
||||
// apply them instead of restoring previousPath.
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
dirCacheRef.current.set(cacheKey, {
|
||||
files,
|
||||
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
|
||||
return "reached";
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== requestId) {
|
||||
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
|
||||
return "aborted";
|
||||
if (!isTargetRequestCurrent()) {
|
||||
return "superseded";
|
||||
}
|
||||
// Side superseded by another tab, but this tab's request is
|
||||
// current — fall through to show the error on this tab.
|
||||
}
|
||||
if (!shouldApplyNavigationState()) {
|
||||
restoreConfirmedStateIfCurrent();
|
||||
return "aborted";
|
||||
}
|
||||
let navigationFailed = false;
|
||||
updateTab(side, targetTabId, (prev) => {
|
||||
if (prev.connection?.id !== connectionId) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
TERMINAL_AUTO_RECONNECT_DELAY_MS,
|
||||
canAttemptTerminalAutoReconnect,
|
||||
isTerminalAutoReconnectEnabled,
|
||||
shouldAutoReconnectAfterExit,
|
||||
shouldContinueAutoReconnectAfterFailure,
|
||||
} from "./terminalAutoReconnect";
|
||||
|
||||
const sshHost = {
|
||||
protocol: "ssh" as const,
|
||||
hostname: "example.com",
|
||||
};
|
||||
|
||||
test("terminal auto reconnect uses a five second retry delay", () => {
|
||||
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
|
||||
});
|
||||
|
||||
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
|
||||
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
|
||||
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
|
||||
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
|
||||
});
|
||||
|
||||
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "closed" },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "closed" },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("normal shell exits do not auto reconnect", () => {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "exited", exitCode: 0 },
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
|
||||
{ protocol: "telnet" as const, hostname: "router.local" },
|
||||
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
|
||||
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
|
||||
];
|
||||
|
||||
for (const host of variants) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt: { reason: "error", error: "connection reset" },
|
||||
host,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an active auto reconnect loop continues after failed retry attempts", () => {
|
||||
assert.equal(
|
||||
shouldContinueAutoReconnectAfterFailure({
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
loopActive: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldContinueAutoReconnectAfterFailure({
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: false },
|
||||
loopActive: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: true,
|
||||
isHibernated: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: false,
|
||||
isHibernated: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: false,
|
||||
isHibernated: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TerminalSettings } from "../../domain/models";
|
||||
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
|
||||
|
||||
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
|
||||
|
||||
type AutoReconnectHost = {
|
||||
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
|
||||
hostname?: string;
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
};
|
||||
|
||||
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
|
||||
|
||||
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
|
||||
return settings?.sshAutoReconnectEnabled === true;
|
||||
}
|
||||
|
||||
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
|
||||
const protocol = host.protocol ?? "ssh";
|
||||
return (
|
||||
protocol === "ssh" &&
|
||||
host.hostname !== "localhost" &&
|
||||
host.moshEnabled !== true &&
|
||||
host.etEnabled !== true
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host,
|
||||
terminalSettings,
|
||||
hasEverConnected,
|
||||
}: {
|
||||
evt: TerminalSessionExitEvent;
|
||||
host: AutoReconnectHost;
|
||||
terminalSettings?: AutoReconnectSettings;
|
||||
hasEverConnected: boolean;
|
||||
}): boolean {
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
return evt.reason !== "exited";
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
host,
|
||||
terminalSettings,
|
||||
loopActive,
|
||||
}: {
|
||||
host: AutoReconnectHost;
|
||||
terminalSettings?: AutoReconnectSettings;
|
||||
loopActive: boolean;
|
||||
}): boolean {
|
||||
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
|
||||
}
|
||||
|
||||
export function canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime,
|
||||
isHibernated,
|
||||
}: {
|
||||
hasTerminalRuntime: boolean;
|
||||
isHibernated: boolean;
|
||||
}): boolean {
|
||||
return hasTerminalRuntime || isHibernated;
|
||||
}
|
||||
@@ -49,6 +49,21 @@ test("split session clones reuse only connected SSH sources", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("session clones preserve the ephemeral-host marker", () => {
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session({ ephemeralHost: true }), { id: "split-1" }).ephemeralHost,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(session({ ephemeralHost: true }), { id: "copy-1" }).ephemeralHost,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(session(), { id: "split-2" }).ephemeralHost,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
const copied = createCopiedTerminalSessionClone(
|
||||
session({
|
||||
@@ -60,3 +75,19 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
|
||||
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
|
||||
});
|
||||
|
||||
test("split and copy session clones preserve local start directory", () => {
|
||||
const source = session({
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project with spaces ",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
createSplitTerminalSessionClone(source, { id: "split-local" }).localStartDir,
|
||||
"/Users/alice/project with spaces ",
|
||||
);
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(source, { id: "copy-local" }).localStartDir,
|
||||
"/Users/alice/project with spaces ",
|
||||
);
|
||||
});
|
||||
@@ -43,8 +43,10 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
localStartDir: session.localStartDir,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loading state
|
||||
*/
|
||||
export const useUIFontsLoading = (): boolean => {
|
||||
return useSyncExternalStore(
|
||||
uiFontStore.subscribe,
|
||||
uiFontStore.getIsLoading
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font loaded state
|
||||
*/
|
||||
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI font by ID with fallback
|
||||
*/
|
||||
export const useUIFontById = (fontId: string): UIFont => {
|
||||
const fonts = useAvailableUIFonts();
|
||||
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a font ID is valid
|
||||
*/
|
||||
|
||||
@@ -4,29 +4,178 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startMainWindowInputFocusRecovery } from "./useMainWindowInputFocusRecovery.ts";
|
||||
|
||||
const root = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root, path), "utf8");
|
||||
}
|
||||
|
||||
test("useMainWindowInputFocusRecovery listens for visibility and window focus", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
type Listener = () => void;
|
||||
|
||||
assert.match(source, /document\.visibilityState !== "visible"/);
|
||||
assert.match(source, /scheduleWindowInputFocus\(\)/);
|
||||
assert.match(source, /document\.addEventListener\("visibilitychange", onVisibilityChange\)/);
|
||||
assert.match(source, /window\.addEventListener\("focus", recoverFocus\)/);
|
||||
function createEventTargetStub() {
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
return {
|
||||
addEventListener(eventName: string, listener: Listener) {
|
||||
const eventListeners = listeners.get(eventName) ?? new Set<Listener>();
|
||||
eventListeners.add(listener);
|
||||
listeners.set(eventName, eventListeners);
|
||||
},
|
||||
removeEventListener(eventName: string, listener: Listener) {
|
||||
listeners.get(eventName)?.delete(listener);
|
||||
},
|
||||
dispatch(eventName: string) {
|
||||
for (const listener of listeners.get(eventName) ?? []) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
listenerCount(eventName: string) {
|
||||
return listeners.get(eventName)?.size ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("main-window input focus recovery ignores generic focus and visible visibility changes", () => {
|
||||
let visibilityState: DocumentVisibilityState = "visible";
|
||||
let scheduleFocusCalls = 0;
|
||||
let cancelFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
let willHideHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
const windowTarget = createEventTargetStub();
|
||||
|
||||
const cleanup = startMainWindowInputFocusRecovery(
|
||||
{ onPageHidden: () => { hiddenCalls += 1; } },
|
||||
{
|
||||
documentRef: {
|
||||
...documentTarget,
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
},
|
||||
windowRef: windowTarget,
|
||||
bridge: {
|
||||
onWindowShown(cb: Listener) {
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
},
|
||||
onWindowWillHide(cb: Listener) {
|
||||
willHideHandler = cb;
|
||||
return () => { willHideHandler = null; };
|
||||
},
|
||||
},
|
||||
scheduleFocus: () => {
|
||||
scheduleFocusCalls += 1;
|
||||
return { cancel: () => { cancelFocusCalls += 1; } };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(windowTarget.listenerCount("focus"), 0);
|
||||
|
||||
windowTarget.dispatch("focus");
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
assert.equal(hiddenCalls, 0);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
willHideHandler?.();
|
||||
|
||||
assert.equal(hiddenCalls, 1);
|
||||
assert.equal(cancelFocusCalls, 1);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 2);
|
||||
|
||||
visibilityState = "hidden";
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(hiddenCalls, 2);
|
||||
assert.equal(cancelFocusCalls, 2);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery dismisses transient UI when the page hides", () => {
|
||||
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
let scheduleFocusCalls = 0;
|
||||
let hiddenCalls = 0;
|
||||
let shownHandler: Listener | null = null;
|
||||
|
||||
const documentTarget = createEventTargetStub();
|
||||
|
||||
const cleanup = startMainWindowInputFocusRecovery(
|
||||
{ onPageHidden: () => { hiddenCalls += 1; } },
|
||||
{
|
||||
documentRef: {
|
||||
...documentTarget,
|
||||
get visibilityState() {
|
||||
return visibilityState;
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
onWindowShown(cb: Listener) {
|
||||
shownHandler = cb;
|
||||
return () => { shownHandler = null; };
|
||||
},
|
||||
},
|
||||
scheduleFocus: () => {
|
||||
scheduleFocusCalls += 1;
|
||||
return { cancel: () => undefined };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
shownHandler?.();
|
||||
|
||||
assert.equal(scheduleFocusCalls, 0);
|
||||
|
||||
visibilityState = "visible";
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
assert.equal(hiddenCalls, 0);
|
||||
|
||||
documentTarget.dispatch("visibilitychange");
|
||||
|
||||
assert.equal(scheduleFocusCalls, 1);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
|
||||
assert.match(source, /document\.visibilityState === "hidden"/);
|
||||
assert.match(source, /visibilityState !== "visible"/);
|
||||
assert.match(source, /scheduleFocus\(\)/);
|
||||
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
|
||||
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
|
||||
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
|
||||
});
|
||||
|
||||
test("useMainWindowInputFocusRecovery uses visibility changes only to dismiss transient UI", () => {
|
||||
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
|
||||
const handlerStart = source.indexOf("const onVisibilityChange");
|
||||
const handlerEnd = source.indexOf('addEventListener("visibilitychange"', handlerStart);
|
||||
const visibilityHandler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(source, /visibilityState === "hidden"/);
|
||||
assert.match(source, /addEventListener\("visibilitychange", onVisibilityChange\)/);
|
||||
assert.match(source, /onPageHidden\?\.\(\)/);
|
||||
assert.match(source, /onWindowShown/);
|
||||
assert.match(source, /onWindowWillHide/);
|
||||
assert.match(source, /cancelPendingFocusRecovery/);
|
||||
assert.match(visibilityHandler, /dismissTransientUi\(\)/);
|
||||
assert.doesNotMatch(visibilityHandler, /else\s*\{\s*recoverFocus\(\);\s*\}/);
|
||||
assert.doesNotMatch(visibilityHandler, /visibilityState !== "hidden"[\s\S]*recoverFocus\(\)/);
|
||||
});
|
||||
|
||||
test("scheduleWindowInputFocus skips deferred focus when the page is hidden", () => {
|
||||
|
||||
@@ -11,9 +11,101 @@ export type MainWindowInputFocusRecoveryOptions = {
|
||||
onPageHidden?: () => void;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
type MainWindowInputFocusRecoveryDocument = {
|
||||
visibilityState: DocumentVisibilityState;
|
||||
addEventListener: (eventName: "visibilitychange", listener: Listener) => void;
|
||||
removeEventListener: (eventName: "visibilitychange", listener: Listener) => void;
|
||||
};
|
||||
|
||||
type MainWindowInputFocusRecoveryWindow = {
|
||||
addEventListener: (eventName: "focus", listener: Listener) => void;
|
||||
removeEventListener: (eventName: "focus", listener: Listener) => void;
|
||||
};
|
||||
|
||||
type MainWindowInputFocusRecoveryBridge = {
|
||||
onWindowShown?: (callback: Listener) => Listener;
|
||||
onWindowWillHide?: (callback: Listener) => Listener;
|
||||
};
|
||||
|
||||
export type MainWindowInputFocusRecoveryDependencies = {
|
||||
documentRef: MainWindowInputFocusRecoveryDocument;
|
||||
windowRef?: MainWindowInputFocusRecoveryWindow;
|
||||
bridge?: MainWindowInputFocusRecoveryBridge;
|
||||
scheduleFocus?: () => ScheduledWindowInputFocus;
|
||||
};
|
||||
|
||||
export function startMainWindowInputFocusRecovery(
|
||||
options: MainWindowInputFocusRecoveryOptions = {},
|
||||
dependencies: MainWindowInputFocusRecoveryDependencies = {
|
||||
documentRef: document,
|
||||
windowRef: window,
|
||||
bridge: netcattyBridge.get(),
|
||||
scheduleFocus: scheduleWindowInputFocus,
|
||||
},
|
||||
): () => void {
|
||||
const { onPageHidden } = options;
|
||||
const {
|
||||
documentRef,
|
||||
bridge,
|
||||
scheduleFocus = scheduleWindowInputFocus,
|
||||
} = dependencies;
|
||||
|
||||
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
|
||||
let pendingExplicitShowRecovery = false;
|
||||
|
||||
const cancelPendingFocusRecovery = () => {
|
||||
pendingFocusRecovery?.cancel();
|
||||
pendingFocusRecovery = null;
|
||||
};
|
||||
|
||||
const recoverFocus = (): boolean => {
|
||||
if (documentRef.visibilityState !== "visible") return false;
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
pendingFocusRecovery = scheduleFocus();
|
||||
return true;
|
||||
};
|
||||
|
||||
const dismissTransientUi = () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
onPageHidden?.();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (documentRef.visibilityState === "hidden") {
|
||||
dismissTransientUi();
|
||||
return;
|
||||
}
|
||||
if (pendingExplicitShowRecovery) {
|
||||
recoverFocus();
|
||||
}
|
||||
};
|
||||
|
||||
documentRef.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
const unsubscribeShown = bridge?.onWindowShown?.(() => {
|
||||
pendingExplicitShowRecovery = true;
|
||||
recoverFocus();
|
||||
});
|
||||
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
|
||||
dismissTransientUi();
|
||||
});
|
||||
|
||||
return () => {
|
||||
pendingExplicitShowRecovery = false;
|
||||
cancelPendingFocusRecovery();
|
||||
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
unsubscribeShown?.();
|
||||
unsubscribeWillHide?.();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover OS/renderer input focus when the main window returns from hide,
|
||||
* another app, or a virtual desktop (#760, #1714, #1722).
|
||||
* Recover OS/renderer input focus when the main process explicitly shows the
|
||||
* main window (#760, #1714, #1722).
|
||||
*/
|
||||
export function useMainWindowInputFocusRecovery(
|
||||
options: MainWindowInputFocusRecoveryOptions = {},
|
||||
@@ -21,49 +113,6 @@ export function useMainWindowInputFocusRecovery(
|
||||
const { onPageHidden } = options;
|
||||
|
||||
useEffect(() => {
|
||||
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
|
||||
|
||||
const cancelPendingFocusRecovery = () => {
|
||||
pendingFocusRecovery?.cancel();
|
||||
pendingFocusRecovery = null;
|
||||
};
|
||||
|
||||
const recoverFocus = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
cancelPendingFocusRecovery();
|
||||
pendingFocusRecovery = scheduleWindowInputFocus();
|
||||
};
|
||||
|
||||
const dismissTransientUi = () => {
|
||||
cancelPendingFocusRecovery();
|
||||
onPageHidden?.();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
dismissTransientUi();
|
||||
return;
|
||||
}
|
||||
recoverFocus();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("focus", recoverFocus);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
const unsubscribeShown = bridge?.onWindowShown?.(() => {
|
||||
recoverFocus();
|
||||
});
|
||||
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
|
||||
dismissTransientUi();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelPendingFocusRecovery();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("focus", recoverFocus);
|
||||
unsubscribeShown?.();
|
||||
unsubscribeWillHide?.();
|
||||
};
|
||||
return startMainWindowInputFocusRecovery({ onPageHidden });
|
||||
}, [onPageHidden]);
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findMatchEndingAfter } from './useOutputTriggers.ts';
|
||||
import {
|
||||
createDeferredOutputTriggerEventProcessor,
|
||||
createOutputTriggerScanBuffer,
|
||||
droppedOutputOverflowMayAffectAlternateScreenState,
|
||||
findMatchEndingAfter,
|
||||
hasApplicableOutputTriggerSnippet,
|
||||
inspectDroppedOutputOverflowAlternateScreenState,
|
||||
} from './useOutputTriggers.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
|
||||
test('findMatchEndingAfter skips stale overlap matches and finds current output', () => {
|
||||
const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n';
|
||||
@@ -27,3 +36,858 @@ test('findMatchEndingAfter returns null when matches are only stale', () => {
|
||||
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
|
||||
assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet ignores non-output-trigger snippets', () => {
|
||||
const snippets: Snippet[] = [
|
||||
{
|
||||
id: 'plain',
|
||||
label: 'Plain',
|
||||
command: 'echo hello',
|
||||
kind: 'snippet',
|
||||
},
|
||||
{
|
||||
id: 'manual-script',
|
||||
label: 'Manual',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'manual',
|
||||
triggerPattern: 'READY',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
|
||||
});
|
||||
|
||||
test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for this host', () => {
|
||||
const snippets: Snippet[] = [
|
||||
{
|
||||
id: 'wrong-host',
|
||||
label: 'Wrong host',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targets: ['host-b'],
|
||||
},
|
||||
{
|
||||
id: 'missing-pattern',
|
||||
label: 'Missing pattern',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
|
||||
|
||||
snippets.push({
|
||||
id: 'right-host',
|
||||
label: 'Right host',
|
||||
command: '',
|
||||
kind: 'script',
|
||||
trigger: 'onOutput',
|
||||
triggerPattern: 'READY',
|
||||
targets: ['host-a'],
|
||||
});
|
||||
|
||||
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
|
||||
});
|
||||
|
||||
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
assert.deepEqual(buffer.append('abcdef'), {
|
||||
text: 'abcdef',
|
||||
minEndOffset: 0,
|
||||
baseOffset: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(buffer.append('gh'), {
|
||||
text: 'cdefgh',
|
||||
minEndOffset: 4,
|
||||
baseOffset: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('output trigger scan buffer scans all new large output instead of only the tail', () => {
|
||||
const buffer = createOutputTriggerScanBuffer(4);
|
||||
|
||||
assert.deepEqual(buffer.append('abc'), {
|
||||
text: 'abc',
|
||||
minEndOffset: 0,
|
||||
baseOffset: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(buffer.append('defghijkl'), {
|
||||
text: 'abcdefghijkl',
|
||||
minEndOffset: 3,
|
||||
baseOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('deferred output trigger processor schedules output instead of processing synchronously', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.deepEqual(processed, []);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['READY']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor coalesces output while preserving input order', () => {
|
||||
const events: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
processOutput: (chunk) => events.push(`output:${chunk}`),
|
||||
processInput: (data) => events.push(`input:${data}`),
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('he');
|
||||
processor.enqueueOutput('llo');
|
||||
processor.enqueueInput('cmd');
|
||||
processor.enqueueOutput('world');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(events, ['output:hello', 'input:cmd', 'output:world']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor splits large output and yields between flushes', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('abcdefghijkl');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh']);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor splits large input and yields between flushes', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => processed.push(chunk),
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdefghijkl');
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh']);
|
||||
assert.equal(scheduled.length, 1);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor caps pending input and resets input state', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let inputOverflows = 0;
|
||||
let inputOverflowLines = 0;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingInputBytes: 8,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => processed.push(chunk),
|
||||
processInputOverflow: (lineCount) => {
|
||||
inputOverflows += 1;
|
||||
inputOverflowLines += lineCount;
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdefghijkl');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(inputOverflows, 1);
|
||||
assert.equal(inputOverflowLines, 1);
|
||||
|
||||
assert.deepEqual(processed, ['efgh', 'ijkl']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor reports dropped input echo line count', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 32,
|
||||
maxPendingInputBytes: 5,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\r\ntwo\nthree');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [3]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor reports more than 1024 dropped input echo lines', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 32,
|
||||
maxPendingInputBytes: 4,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(`${'x\n'.repeat(1100)}tail`);
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [1101]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor includes retained input lines in overflow echo suppression', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const retainedInput = 'READY c\nREADY d\n';
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 128,
|
||||
maxPendingInputBytes: retainedInput.length,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(`READY a\nREADY b\n${retainedInput}`);
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [4]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor accumulates repeated input overflows before flush', () => {
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\ntwo\n');
|
||||
processor.enqueueInput('three\nfour\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [4]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor counts input split across overflow boundary as one line', () => {
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 'def\n'.length,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueInput('abcdef\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(overflowLines, [1]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor resets input overflow accounting after flush', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const overflowLines: number[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
overflowLines.push(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput('one\ntwo\n');
|
||||
scheduled.shift()?.();
|
||||
processor.enqueueInput('three\n');
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(overflowLines, [2, 1]);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves output before later input overflow', () => {
|
||||
const processed: string[] = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxPendingInputBytes: 0,
|
||||
processOutput: (chunk) => {
|
||||
processed.push(`output:${chunk}`);
|
||||
},
|
||||
processInput: () => {},
|
||||
processInputOverflow: (lineCount) => {
|
||||
processed.push(`overflow:${lineCount}`);
|
||||
},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY\n');
|
||||
processor.enqueueInput('abcdef\n');
|
||||
processor.flush();
|
||||
|
||||
assert.deepEqual(processed, ['output:READY\n', 'overflow:1']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor suppresses retained multi-line input echo after overflow', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const retainedInput = 'READY c\nREADY d\n';
|
||||
const fullInput = `READY a\nREADY b\n${retainedInput}`;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 128,
|
||||
maxPendingInputBytes: retainedInput.length,
|
||||
processOutput: () => {},
|
||||
processInput: (chunk) => {
|
||||
filter.noteUserInput(chunk);
|
||||
},
|
||||
processInputOverflow: (lineCount) => {
|
||||
filter.markInputEchoUncertain(lineCount);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueInput(fullInput);
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
const result = filter.processServerChunk(`${fullInput}server READY\n`);
|
||||
assert.equal(result.scannableText, 'server READY\n');
|
||||
});
|
||||
|
||||
test('deferred output trigger processor keeps pending scan output bounded', () => {
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 4,
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(80));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.equal(processor.getPendingOutputBytes(), 12);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(processed.join('').length, 12);
|
||||
assert.equal(processed.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor defers dropped output maintenance work', () => {
|
||||
const dropped: string[] = [];
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => dropped.push(chunk),
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(40));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.deepEqual(dropped, []);
|
||||
assert.deepEqual(processed, []);
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.equal(dropped.join('').length, 8);
|
||||
assert.deepEqual(processed, []);
|
||||
assert.equal(scheduled.length, 1);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor applies output metadata in queue order', () => {
|
||||
const scanned: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let suppressed = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
processOutput: (chunk, meta) => {
|
||||
if (meta?.droppedOutputMayAffectTerminalState) {
|
||||
suppressed = true;
|
||||
}
|
||||
if (!suppressed) {
|
||||
scanned.push(chunk);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('READY');
|
||||
processor.enqueueOutput('AFTER_DROP', { droppedOutputMayAffectTerminalState: true });
|
||||
|
||||
scheduled.shift()?.();
|
||||
|
||||
assert.deepEqual(scanned, ['READY']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves metadata when trimmed output is fully dropped', () => {
|
||||
const scanned: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let suppressed = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 64,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 5,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (_chunk, meta) => {
|
||||
if (meta?.droppedOutputMayAffectTerminalState) {
|
||||
suppressed = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
if (!suppressed) {
|
||||
scanned.push(chunk);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('DROP', { droppedOutputMayAffectTerminalState: true });
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scanned, []);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor caps dropped maintenance before retained output', () => {
|
||||
const dropped: string[] = [];
|
||||
const processed: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => dropped.push(chunk),
|
||||
processOutput: (chunk) => processed.push(chunk),
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(200));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
scheduled.shift()?.();
|
||||
assert.equal(dropped.join('').length, 8);
|
||||
assert.deepEqual(processed, []);
|
||||
|
||||
scheduled.shift()?.();
|
||||
assert.equal(processed.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor inspects dropped overflow with retained context', () => {
|
||||
let mayAffectAlternateScreen = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputBytesPerFlush: 5,
|
||||
maxPendingOutputBytes: 0,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: () => {},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
|
||||
},
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
schedule: () => () => {},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?10');
|
||||
processor.enqueueOutput('49hREADY');
|
||||
|
||||
assert.equal(mayAffectAlternateScreen, true);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor does not carry dropped context across real output', () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
let mayAffectAlternateScreen = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 8,
|
||||
maxOutputBytesPerFlush: 3,
|
||||
maxPendingOutputBytes: 1,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: () => {},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
|
||||
},
|
||||
processOutput: () => {},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?Z');
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
processor.enqueueOutput('1049hREADY');
|
||||
|
||||
assert.equal(mayAffectAlternateScreen, false);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor suppresses retained scans after dropped maintenance overflow', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
|
||||
suppressScan = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput(`${'x'.repeat(40)}\x1b[?1049h${'y'.repeat(40)}`);
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, true);
|
||||
assert.deepEqual(scannable, []);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor can recover from dropped split alternate-screen leave', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const recoveryFilter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = true;
|
||||
const maybeRecover = (chunk: string) => {
|
||||
const recovery = recoveryFilter.processServerChunk(chunk);
|
||||
if (recovery.meta.alternateScreenAction === 'leave' && !recovery.alternateScreenActive) {
|
||||
suppressScan = false;
|
||||
recoveryFilter.reset();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 16,
|
||||
maxOutputBytesPerFlush: 16,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
if (suppressScan) {
|
||||
maybeRecover(chunk);
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (suppressScan) {
|
||||
maybeRecover(chunk);
|
||||
return;
|
||||
}
|
||||
if (result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('\x1b[?10');
|
||||
processor.enqueueOutput('49lREADY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, false);
|
||||
assert.deepEqual(scannable, []);
|
||||
|
||||
processor.enqueueOutput('NEXT');
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scannable, ['NEXT']);
|
||||
});
|
||||
|
||||
test('deferred output trigger processor keeps scanning after plain dropped maintenance overflow', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
let suppressScan = false;
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 8,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processDroppedOutputOverflow: (overflow) => {
|
||||
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
|
||||
suppressScan = true;
|
||||
}
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput('x'.repeat(200));
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.equal(suppressScan, false);
|
||||
assert.equal(scannable.join('').endsWith('READY'), true);
|
||||
});
|
||||
|
||||
test('dropped output overflow detection ignores color sequences but catches alternate screen candidates', () => {
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: '\x1b[31mcolored text',
|
||||
}), false);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: '\x1b[?10',
|
||||
discardedSuffix: '49hmore output',
|
||||
}), true);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: '\x1b[?1;1049l',
|
||||
}), true);
|
||||
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
|
||||
retainedPrefix: `before\x1b[?1;2;3;4;5;6;7;8;9;10`,
|
||||
discardedSuffix: ';1049hafter',
|
||||
}), true);
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: '\x1b[?1049h',
|
||||
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1;1049l`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: 'leave',
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `\x1b[?1049l${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: false,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1049lplain\x1b[?10`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: 'plain',
|
||||
discardedSuffix: `\x1b[?1049l${'x'.repeat(2500)}\x1b[?10`,
|
||||
}), {
|
||||
mayAffectAlternateScreen: true,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix: '\x1b[31',
|
||||
discardedSuffix: 'mREADY',
|
||||
}), {
|
||||
mayAffectAlternateScreen: false,
|
||||
mayAffectScanState: true,
|
||||
finalAction: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('deferred output trigger processor preserves filter state for trimmed output', () => {
|
||||
const scannable: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const filter = createTerminalOutputTriggerFilter();
|
||||
const processor = createDeferredOutputTriggerEventProcessor({
|
||||
maxOutputChunkBytes: 4,
|
||||
maxOutputBytesPerFlush: 64,
|
||||
maxPendingOutputBytes: 8,
|
||||
pendingOutputOverlapChars: 0,
|
||||
processDroppedOutput: (chunk) => {
|
||||
filter.processServerChunk(chunk);
|
||||
},
|
||||
processOutput: (chunk) => {
|
||||
const result = filter.processServerChunk(chunk);
|
||||
if (result.scannableText && !result.alternateScreenActive) {
|
||||
scannable.push(result.scannableText);
|
||||
}
|
||||
},
|
||||
processInput: () => {},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
|
||||
processor.enqueueOutput(`\x1b[?1049h${'x'.repeat(80)}`);
|
||||
processor.enqueueOutput('READY');
|
||||
|
||||
assert.equal(processor.getPendingOutputBytes(), 8);
|
||||
|
||||
while (scheduled.length > 0) {
|
||||
scheduled.shift()?.();
|
||||
}
|
||||
|
||||
assert.deepEqual(scannable, []);
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
|
||||
import { isScriptSnippet } from '@/domain/snippetScript.ts';
|
||||
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import {
|
||||
createTerminalOutputTriggerFilter,
|
||||
getTerminalAlternateScreenAction,
|
||||
} from '@/domain/terminalOutputTriggerFilter.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
import { getActiveScriptRunForSession } from '@/application/state/scriptAutomationCoordinator.ts';
|
||||
|
||||
const OUTPUT_TRIGGER_SCAN_DELAY_MS = 16;
|
||||
const OUTPUT_TRIGGER_SCAN_CHUNK_BYTES = 32 * 1024;
|
||||
const OUTPUT_TRIGGER_SCAN_BYTES_PER_FLUSH = 128 * 1024;
|
||||
const OUTPUT_TRIGGER_SCAN_TIME_BUDGET_MS = 4;
|
||||
const OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS = 64;
|
||||
const OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES = 512 * 1024;
|
||||
const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
|
||||
|
||||
type OutputTriggerContext = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
@@ -13,10 +24,542 @@ type OutputTriggerContext = {
|
||||
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type DeferredOutputTriggerEvent =
|
||||
| { type: 'output'; chunk: string; meta?: OutputTriggerAppendMeta }
|
||||
| { type: 'dropped-output'; chunk: string; meta?: OutputTriggerAppendMeta }
|
||||
| { type: 'input-overflow'; droppedCounter: InputEchoLineCounter }
|
||||
| { type: 'input'; data: string };
|
||||
|
||||
type DeferredOutputTriggerEventProcessor = {
|
||||
enqueueOutput: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
|
||||
enqueueInput: (data: string) => void;
|
||||
flush: () => void;
|
||||
reset: () => void;
|
||||
getPendingOutputBytes: () => number;
|
||||
};
|
||||
|
||||
type OutputTriggerAppendMeta = {
|
||||
droppedOutputMayAffectTerminalState?: boolean;
|
||||
droppedOutputAlternateScreenAction?: 'enter' | 'leave';
|
||||
};
|
||||
|
||||
type TerminalOutputTriggerFilterResult = ReturnType<
|
||||
ReturnType<typeof createTerminalOutputTriggerFilter>['processServerChunk']
|
||||
>;
|
||||
|
||||
type DeferredOutputTriggerEventProcessorOptions = {
|
||||
processOutput: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
|
||||
processDroppedOutput?: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
|
||||
processDroppedOutputOverflow?: (overflow: {
|
||||
retainedPrefix: string;
|
||||
discardedSuffix: string;
|
||||
}) => void;
|
||||
processInput: (data: string) => void;
|
||||
processInputOverflow?: (lineCount: number) => void;
|
||||
schedule: (callback: () => void) => () => void;
|
||||
now?: () => number;
|
||||
maxOutputChunkBytes?: number;
|
||||
maxOutputBytesPerFlush?: number;
|
||||
maxFlushMs?: number;
|
||||
maxPendingOutputBytes?: number;
|
||||
maxPendingInputBytes?: number;
|
||||
pendingOutputOverlapChars?: number;
|
||||
};
|
||||
|
||||
type DroppedOutputOverflowAlternateScreenInspection = {
|
||||
mayAffectAlternateScreen: boolean;
|
||||
mayAffectScanState: boolean;
|
||||
finalAction?: 'enter' | 'leave';
|
||||
};
|
||||
|
||||
type OutputTriggerScanWindow = {
|
||||
text: string;
|
||||
minEndOffset: number;
|
||||
baseOffset: number;
|
||||
};
|
||||
|
||||
type OutputTriggerScanBuffer = {
|
||||
append: (chunk: string) => OutputTriggerScanWindow;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
type InputEchoLineCounter = {
|
||||
lineCount: number;
|
||||
hasOpenLine: boolean;
|
||||
pendingCr: boolean;
|
||||
sawInput: boolean;
|
||||
startsWithLf: boolean;
|
||||
};
|
||||
|
||||
function isSessionScriptRunActive(sessionId: string): boolean {
|
||||
return Boolean(getActiveScriptRunForSession(sessionId));
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_MODE_PREFIXES = ['47', '1047', '1049'];
|
||||
const ESC = String.fromCharCode(27);
|
||||
|
||||
function readCsiSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
|
||||
if (input[startIndex] !== ESC || input[startIndex + 1] !== '[') return null;
|
||||
for (let index = startIndex + 2; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return {
|
||||
sequence: input.slice(startIndex, index + 1),
|
||||
end: index + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPotentialAlternateScreenCsiPrefix(sequence: string): boolean {
|
||||
if (!sequence.startsWith(`${ESC}[?`)) return false;
|
||||
const params = sequence.slice(3);
|
||||
if (!params) return true;
|
||||
return params
|
||||
.split(';')
|
||||
.some((part) => (
|
||||
part === ''
|
||||
|| ALTERNATE_SCREEN_MODE_PREFIXES.some((mode) => (
|
||||
mode.startsWith(part) || part.startsWith(mode)
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
function inspectAlternateScreenActionsInWindow(
|
||||
text: string,
|
||||
discardedStart: number,
|
||||
): DroppedOutputOverflowAlternateScreenInspection {
|
||||
let mayAffectAlternateScreen = false;
|
||||
let mayAffectScanState = false;
|
||||
let finalAction: 'enter' | 'leave' | undefined;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text[index] !== ESC) continue;
|
||||
const sequence = readCsiSequence(text, index);
|
||||
if (sequence) {
|
||||
if (sequence.end <= discardedStart) {
|
||||
index = sequence.end - 1;
|
||||
continue;
|
||||
}
|
||||
if (index < discardedStart) {
|
||||
mayAffectScanState = true;
|
||||
}
|
||||
const action = getTerminalAlternateScreenAction(sequence.sequence);
|
||||
if (action) {
|
||||
mayAffectAlternateScreen = true;
|
||||
finalAction = action;
|
||||
}
|
||||
index = sequence.end - 1;
|
||||
continue;
|
||||
}
|
||||
if (text.length > discardedStart) {
|
||||
mayAffectScanState = true;
|
||||
}
|
||||
if (
|
||||
index < text.length
|
||||
&& text.length > discardedStart
|
||||
&& isPotentialAlternateScreenCsiPrefix(text.slice(index))
|
||||
) {
|
||||
mayAffectAlternateScreen = true;
|
||||
finalAction = undefined;
|
||||
}
|
||||
}
|
||||
return { mayAffectAlternateScreen, mayAffectScanState, finalAction };
|
||||
}
|
||||
|
||||
export function inspectDroppedOutputOverflowAlternateScreenState({
|
||||
retainedPrefix,
|
||||
discardedSuffix,
|
||||
}: {
|
||||
retainedPrefix: string;
|
||||
discardedSuffix: string;
|
||||
}): DroppedOutputOverflowAlternateScreenInspection {
|
||||
if (!discardedSuffix) return { mayAffectAlternateScreen: false, mayAffectScanState: false };
|
||||
const retainedTail = retainedPrefix.slice(-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
|
||||
const lastRetainedEscape = retainedTail.lastIndexOf(ESC);
|
||||
const retainedContext = lastRetainedEscape >= 0 ? retainedTail.slice(lastRetainedEscape) : '';
|
||||
const discardedHead = discardedSuffix.slice(0, OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
|
||||
const headInspection = inspectAlternateScreenActionsInWindow(
|
||||
`${retainedContext}${discardedHead}`,
|
||||
retainedContext.length,
|
||||
);
|
||||
if (discardedSuffix.length <= OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS) {
|
||||
return headInspection;
|
||||
}
|
||||
const discardedTail = discardedSuffix.slice(-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
|
||||
const tailInspection = inspectAlternateScreenActionsInWindow(discardedTail, 0);
|
||||
const hasUninspectedMiddle = discardedSuffix.length > OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS * 2;
|
||||
return {
|
||||
mayAffectAlternateScreen: (
|
||||
headInspection.mayAffectAlternateScreen
|
||||
|| tailInspection.mayAffectAlternateScreen
|
||||
|| (hasUninspectedMiddle && tailInspection.finalAction !== 'leave')
|
||||
),
|
||||
mayAffectScanState: (
|
||||
headInspection.mayAffectScanState
|
||||
|| tailInspection.mayAffectScanState
|
||||
),
|
||||
finalAction: tailInspection.finalAction
|
||||
?? (hasUninspectedMiddle || tailInspection.mayAffectAlternateScreen ? undefined : headInspection.finalAction),
|
||||
};
|
||||
}
|
||||
|
||||
export function droppedOutputOverflowMayAffectAlternateScreenState(overflow: {
|
||||
retainedPrefix: string;
|
||||
discardedSuffix: string;
|
||||
}): boolean {
|
||||
return inspectDroppedOutputOverflowAlternateScreenState(overflow).mayAffectAlternateScreen;
|
||||
}
|
||||
|
||||
export function createOutputTriggerScanBuffer(
|
||||
overlapChars = OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS,
|
||||
): OutputTriggerScanBuffer {
|
||||
let overlap = '';
|
||||
let consumedLength = 0;
|
||||
|
||||
return {
|
||||
append(chunk: string) {
|
||||
const text = `${overlap}${chunk}`;
|
||||
const minEndOffset = overlap.length;
|
||||
const baseOffset = consumedLength - overlap.length;
|
||||
consumedLength += chunk.length;
|
||||
overlap = text.slice(-overlapChars);
|
||||
return { text, minEndOffset, baseOffset };
|
||||
},
|
||||
reset() {
|
||||
overlap = '';
|
||||
consumedLength = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createInputEchoLineCounter(): InputEchoLineCounter {
|
||||
return {
|
||||
lineCount: 0,
|
||||
hasOpenLine: false,
|
||||
pendingCr: false,
|
||||
sawInput: false,
|
||||
startsWithLf: false,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneInputEchoLineCounter(counter: InputEchoLineCounter): InputEchoLineCounter {
|
||||
return { ...counter };
|
||||
}
|
||||
|
||||
function appendInputEchoLineCounter(counter: InputEchoLineCounter, text: string): void {
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
const wasEmpty = !counter.sawInput;
|
||||
counter.sawInput = true;
|
||||
if (wasEmpty && char === '\n') {
|
||||
counter.startsWithLf = true;
|
||||
}
|
||||
if (counter.pendingCr) {
|
||||
counter.pendingCr = false;
|
||||
if (char === '\n') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (char === '\r') {
|
||||
counter.lineCount += 1;
|
||||
counter.hasOpenLine = false;
|
||||
counter.pendingCr = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '\n') {
|
||||
counter.lineCount += 1;
|
||||
counter.hasOpenLine = false;
|
||||
continue;
|
||||
}
|
||||
counter.hasOpenLine = true;
|
||||
}
|
||||
}
|
||||
|
||||
function appendInputEchoCounterState(
|
||||
counter: InputEchoLineCounter,
|
||||
source: InputEchoLineCounter,
|
||||
): void {
|
||||
if (!source.sawInput) return;
|
||||
if (!counter.sawInput) {
|
||||
Object.assign(counter, cloneInputEchoLineCounter(source));
|
||||
return;
|
||||
}
|
||||
const crlfBoundaryAdjustment = counter.pendingCr && source.startsWithLf ? 1 : 0;
|
||||
counter.lineCount += source.lineCount - crlfBoundaryAdjustment;
|
||||
counter.hasOpenLine = source.hasOpenLine;
|
||||
counter.pendingCr = source.pendingCr;
|
||||
counter.sawInput = true;
|
||||
}
|
||||
|
||||
function getInputEchoLineCount(counter: InputEchoLineCounter): number {
|
||||
if (!counter.sawInput) return 0;
|
||||
return Math.max(1, counter.lineCount + (counter.hasOpenLine ? 1 : 0));
|
||||
}
|
||||
|
||||
export function createDeferredOutputTriggerEventProcessor({
|
||||
processOutput,
|
||||
processDroppedOutput,
|
||||
processDroppedOutputOverflow,
|
||||
processInput,
|
||||
processInputOverflow,
|
||||
schedule,
|
||||
now = () => performance.now(),
|
||||
maxOutputChunkBytes = OUTPUT_TRIGGER_SCAN_CHUNK_BYTES,
|
||||
maxOutputBytesPerFlush = OUTPUT_TRIGGER_SCAN_BYTES_PER_FLUSH,
|
||||
maxFlushMs = OUTPUT_TRIGGER_SCAN_TIME_BUDGET_MS,
|
||||
maxPendingOutputBytes = OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES,
|
||||
maxPendingInputBytes = OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES,
|
||||
pendingOutputOverlapChars = OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS,
|
||||
}: DeferredOutputTriggerEventProcessorOptions): DeferredOutputTriggerEventProcessor {
|
||||
const events: DeferredOutputTriggerEvent[] = [];
|
||||
let cancelScheduledFlush: (() => void) | null = null;
|
||||
let pendingOutputBytes = 0;
|
||||
let pendingDroppedOutputBytes = 0;
|
||||
let pendingInputBytes = 0;
|
||||
let droppedOutputContextTail = '';
|
||||
const maxDroppedOutputBytes = Math.max(0, maxOutputBytesPerFlush);
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (cancelScheduledFlush) return;
|
||||
cancelScheduledFlush = schedule(() => {
|
||||
cancelScheduledFlush = null;
|
||||
processor.flush();
|
||||
});
|
||||
};
|
||||
|
||||
const trimPendingOutput = () => {
|
||||
const maxPendingBytes = Math.max(0, maxPendingOutputBytes);
|
||||
const overlapBytes = Math.max(0, pendingOutputOverlapChars);
|
||||
const maxRetainedBytes = maxPendingBytes + overlapBytes;
|
||||
if (pendingOutputBytes <= maxRetainedBytes) return;
|
||||
|
||||
const takeDroppedMaintenanceChunk = (chunk: string) => {
|
||||
if (!processDroppedOutput || !chunk) return '';
|
||||
const availableBytes = Math.max(0, maxDroppedOutputBytes - pendingDroppedOutputBytes);
|
||||
if (availableBytes <= 0) {
|
||||
processDroppedOutputOverflow?.({
|
||||
retainedPrefix: droppedOutputContextTail,
|
||||
discardedSuffix: chunk,
|
||||
});
|
||||
return '';
|
||||
}
|
||||
const retained = chunk.length > availableBytes ? chunk.slice(0, availableBytes) : chunk;
|
||||
if (retained.length < chunk.length) {
|
||||
processDroppedOutputOverflow?.({
|
||||
retainedPrefix: `${droppedOutputContextTail}${retained}`,
|
||||
discardedSuffix: chunk.slice(retained.length),
|
||||
});
|
||||
}
|
||||
droppedOutputContextTail = `${droppedOutputContextTail}${retained}`.slice(
|
||||
-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS,
|
||||
);
|
||||
pendingDroppedOutputBytes += retained.length;
|
||||
return retained;
|
||||
};
|
||||
|
||||
let bytesToDrop = pendingOutputBytes - maxRetainedBytes;
|
||||
for (let index = 0; index < events.length && bytesToDrop > 0; index += 1) {
|
||||
const event = events[index];
|
||||
if (event.type === 'input' || event.type === 'input-overflow') {
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'dropped-output') continue;
|
||||
if (event.chunk.length <= bytesToDrop) {
|
||||
bytesToDrop -= event.chunk.length;
|
||||
pendingOutputBytes -= event.chunk.length;
|
||||
const droppedMeta = event.meta;
|
||||
const droppedChunk = takeDroppedMaintenanceChunk(event.chunk);
|
||||
if (droppedChunk || droppedMeta) {
|
||||
events[index] = droppedMeta
|
||||
? { type: 'dropped-output', chunk: droppedChunk, meta: droppedMeta }
|
||||
: { type: 'dropped-output', chunk: droppedChunk };
|
||||
} else {
|
||||
events.splice(index, 1);
|
||||
index -= 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const droppedChunk = event.chunk.slice(0, bytesToDrop);
|
||||
event.chunk = event.chunk.slice(bytesToDrop);
|
||||
pendingOutputBytes -= bytesToDrop;
|
||||
const droppedMeta = event.meta;
|
||||
event.meta = undefined;
|
||||
const retainedDroppedChunk = takeDroppedMaintenanceChunk(droppedChunk);
|
||||
if (retainedDroppedChunk || droppedMeta) {
|
||||
events.splice(index, 0, droppedMeta
|
||||
? { type: 'dropped-output', chunk: retainedDroppedChunk, meta: droppedMeta }
|
||||
: { type: 'dropped-output', chunk: retainedDroppedChunk });
|
||||
}
|
||||
bytesToDrop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const trimPendingInput = () => {
|
||||
const maxPendingBytes = Math.max(0, maxPendingInputBytes);
|
||||
if (pendingInputBytes <= maxPendingBytes) return;
|
||||
|
||||
let bytesToDrop = pendingInputBytes - maxPendingBytes;
|
||||
const nextEvents: DeferredOutputTriggerEvent[] = [];
|
||||
const appendOverflowEvent = (droppedCounter: InputEchoLineCounter) => {
|
||||
if (!droppedCounter.sawInput) return;
|
||||
const previousEvent = nextEvents.at(-1);
|
||||
if (previousEvent?.type === 'input-overflow') {
|
||||
appendInputEchoCounterState(previousEvent.droppedCounter, droppedCounter);
|
||||
return;
|
||||
}
|
||||
nextEvents.push({ type: 'input-overflow', droppedCounter });
|
||||
};
|
||||
|
||||
for (let index = 0; index < events.length; index += 1) {
|
||||
const event = events[index];
|
||||
if (event.type !== 'input') {
|
||||
nextEvents.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
const droppedCounter = createInputEchoLineCounter();
|
||||
const retainedChunks: string[] = [];
|
||||
for (; index < events.length; index += 1) {
|
||||
const inputEvent = events[index];
|
||||
if (inputEvent.type !== 'input') {
|
||||
index -= 1;
|
||||
break;
|
||||
}
|
||||
if (bytesToDrop <= 0) {
|
||||
retainedChunks.push(inputEvent.data);
|
||||
continue;
|
||||
}
|
||||
if (inputEvent.data.length <= bytesToDrop) {
|
||||
bytesToDrop -= inputEvent.data.length;
|
||||
pendingInputBytes -= inputEvent.data.length;
|
||||
appendInputEchoLineCounter(droppedCounter, inputEvent.data);
|
||||
continue;
|
||||
}
|
||||
const droppedInputChunk = inputEvent.data.slice(0, bytesToDrop);
|
||||
const retainedInputChunk = inputEvent.data.slice(bytesToDrop);
|
||||
pendingInputBytes -= bytesToDrop;
|
||||
appendInputEchoLineCounter(droppedCounter, droppedInputChunk);
|
||||
retainedChunks.push(retainedInputChunk);
|
||||
bytesToDrop = 0;
|
||||
}
|
||||
|
||||
appendOverflowEvent(droppedCounter);
|
||||
if (retainedChunks.length > 0) {
|
||||
nextEvents.push({ type: 'input', data: retainedChunks.join('') });
|
||||
}
|
||||
}
|
||||
events.splice(0, events.length, ...nextEvents);
|
||||
};
|
||||
|
||||
const processor: DeferredOutputTriggerEventProcessor = {
|
||||
enqueueOutput(chunk: string, meta?: OutputTriggerAppendMeta) {
|
||||
if (!chunk) return;
|
||||
const lastEvent = events.at(-1);
|
||||
if (
|
||||
lastEvent?.type === 'output'
|
||||
&& !lastEvent.meta
|
||||
&& !meta
|
||||
&& lastEvent.chunk.length + chunk.length <= maxOutputBytesPerFlush
|
||||
) {
|
||||
lastEvent.chunk += chunk;
|
||||
} else {
|
||||
events.push(meta ? { type: 'output', chunk, meta } : { type: 'output', chunk });
|
||||
}
|
||||
pendingOutputBytes += chunk.length;
|
||||
trimPendingOutput();
|
||||
scheduleFlush();
|
||||
},
|
||||
enqueueInput(data: string) {
|
||||
if (!data) return;
|
||||
const lastEvent = events.at(-1);
|
||||
if (lastEvent?.type === 'input') {
|
||||
lastEvent.data += data;
|
||||
} else {
|
||||
events.push({ type: 'input', data });
|
||||
}
|
||||
pendingInputBytes += data.length;
|
||||
trimPendingInput();
|
||||
scheduleFlush();
|
||||
},
|
||||
flush() {
|
||||
const startedAt = now();
|
||||
let processedOutputBytes = 0;
|
||||
|
||||
while (events.length > 0) {
|
||||
const event = events[0];
|
||||
if (event.type === 'input') {
|
||||
droppedOutputContextTail = '';
|
||||
const chunk = event.data.slice(0, maxOutputChunkBytes);
|
||||
event.data = event.data.slice(chunk.length);
|
||||
if (!event.data) {
|
||||
events.shift();
|
||||
}
|
||||
pendingInputBytes = Math.max(0, pendingInputBytes - chunk.length);
|
||||
processedOutputBytes += chunk.length;
|
||||
processInput(chunk);
|
||||
} else if (event.type === 'input-overflow') {
|
||||
const inputCounter = cloneInputEchoLineCounter(event.droppedCounter);
|
||||
events.shift();
|
||||
for (const pendingEvent of events) {
|
||||
if (pendingEvent.type !== 'input') break;
|
||||
appendInputEchoLineCounter(inputCounter, pendingEvent.data);
|
||||
}
|
||||
processInputOverflow?.(getInputEchoLineCount(inputCounter));
|
||||
} else if (event.type === 'dropped-output') {
|
||||
const chunk = event.chunk.slice(0, maxOutputChunkBytes);
|
||||
event.chunk = event.chunk.slice(chunk.length);
|
||||
const meta = event.meta;
|
||||
event.meta = undefined;
|
||||
if (!event.chunk) {
|
||||
events.shift();
|
||||
}
|
||||
pendingDroppedOutputBytes = Math.max(0, pendingDroppedOutputBytes - chunk.length);
|
||||
processedOutputBytes += chunk.length;
|
||||
processDroppedOutput?.(chunk, meta);
|
||||
} else {
|
||||
droppedOutputContextTail = '';
|
||||
const chunk = event.chunk.slice(0, maxOutputChunkBytes);
|
||||
event.chunk = event.chunk.slice(chunk.length);
|
||||
pendingOutputBytes -= chunk.length;
|
||||
if (!event.chunk) {
|
||||
events.shift();
|
||||
}
|
||||
processedOutputBytes += chunk.length;
|
||||
const meta = event.meta;
|
||||
event.meta = undefined;
|
||||
processOutput(chunk, meta);
|
||||
}
|
||||
|
||||
if (
|
||||
events.length > 0
|
||||
&& (
|
||||
processedOutputBytes >= maxOutputBytesPerFlush
|
||||
|| now() - startedAt >= maxFlushMs
|
||||
)
|
||||
) {
|
||||
scheduleFlush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
events.splice(0, events.length);
|
||||
pendingOutputBytes = 0;
|
||||
pendingDroppedOutputBytes = 0;
|
||||
pendingInputBytes = 0;
|
||||
droppedOutputContextTail = '';
|
||||
cancelScheduledFlush?.();
|
||||
cancelScheduledFlush = null;
|
||||
},
|
||||
getPendingOutputBytes() {
|
||||
return pendingOutputBytes;
|
||||
},
|
||||
};
|
||||
|
||||
return processor;
|
||||
}
|
||||
|
||||
export function findMatchEndingAfter(text: string, pattern: string, minEndOffset: number): { value: string; endOffset: number } | null {
|
||||
const source = new RegExp(pattern);
|
||||
for (let startOffset = 0; startOffset <= text.length;) {
|
||||
@@ -32,27 +575,44 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
|
||||
return snippets.some((snippet) => (
|
||||
isScriptSnippet(snippet)
|
||||
&& snippet.trigger === 'onOutput'
|
||||
&& Boolean(snippet.triggerPattern)
|
||||
&& Boolean(snippet.id)
|
||||
&& snippetAppliesToOutputTrigger(snippet, hostId)
|
||||
));
|
||||
}
|
||||
|
||||
export function useOutputTriggers({
|
||||
sessionId,
|
||||
hostId,
|
||||
snippets,
|
||||
onRunScript,
|
||||
}: OutputTriggerContext) {
|
||||
const bufferRef = useRef('');
|
||||
const launchingRef = useRef(false);
|
||||
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
|
||||
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
const outputTriggerSuppressionRecoveryFilterRef = useRef(createTerminalOutputTriggerFilter());
|
||||
const scanBufferRef = useRef(createOutputTriggerScanBuffer());
|
||||
const outputTriggerScanSuppressedRef = useRef(false);
|
||||
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
|
||||
const pendingDroppedOverflowScanStateResetRef = useRef(false);
|
||||
const hasOutputTriggers = useMemo(
|
||||
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
|
||||
[hostId, snippets],
|
||||
);
|
||||
|
||||
const scanBuffer = useCallback((recentChunk: string) => {
|
||||
if (!recentChunk || isSessionScriptRunActive(sessionId) || launchingRef.current) {
|
||||
const scanOutput = useCallback((scannableText: string) => {
|
||||
if (!hasOutputTriggers || !scannableText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = bufferRef.current;
|
||||
const overlap = 64;
|
||||
const chunkWithOverlap = text.slice(Math.max(0, text.length - recentChunk.length - overlap));
|
||||
const chunkStartInSlice = Math.max(0, chunkWithOverlap.length - recentChunk.length);
|
||||
const chunkBaseOffset = text.length - chunkWithOverlap.length;
|
||||
const scanWindow = scanBufferRef.current.append(scannableText);
|
||||
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const snippet of snippets) {
|
||||
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
|
||||
@@ -63,11 +623,11 @@ export function useOutputTriggers({
|
||||
}
|
||||
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
|
||||
try {
|
||||
const matched = findMatchEndingAfter(chunkWithOverlap, snippet.triggerPattern, chunkStartInSlice);
|
||||
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
const matchEnd = chunkBaseOffset + matched.endOffset;
|
||||
const matchEnd = scanWindow.baseOffset + matched.endOffset;
|
||||
const lastMatchEnd = lastTriggerMatchEndRef.current.get(snippet.id) ?? -1;
|
||||
if (matchEnd <= lastMatchEnd) {
|
||||
continue;
|
||||
@@ -87,29 +647,177 @@ export function useOutputTriggers({
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
}, [hostId, onRunScript, sessionId, snippets]);
|
||||
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
|
||||
|
||||
const appendOutput = useCallback((chunk: string) => {
|
||||
if (!chunk) return;
|
||||
const scanOutputRef = useRef(scanOutput);
|
||||
scanOutputRef.current = scanOutput;
|
||||
|
||||
const resetTriggerScanState = useCallback(() => {
|
||||
scanBufferRef.current.reset();
|
||||
lastTriggerMatchEndRef.current = new Map();
|
||||
}, []);
|
||||
|
||||
const restoreOutputTriggerScanAfterDroppedLeave = useCallback((resetServerFilter = true) => {
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
if (resetServerFilter) {
|
||||
serverOutputFilterRef.current.reset();
|
||||
}
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.reset();
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
resetTriggerScanState();
|
||||
}, [resetTriggerScanState]);
|
||||
|
||||
const applyPendingDroppedOverflowFinalAction = useCallback(() => {
|
||||
if (pendingDroppedOverflowFinalActionRef.current !== 'leave') {
|
||||
return;
|
||||
}
|
||||
restoreOutputTriggerScanAfterDroppedLeave();
|
||||
}, [restoreOutputTriggerScanAfterDroppedLeave]);
|
||||
|
||||
const applyPendingDroppedOverflowScanStateReset = useCallback(() => {
|
||||
if (!pendingDroppedOverflowScanStateResetRef.current) {
|
||||
return;
|
||||
}
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
serverOutputFilterRef.current.resetPendingEscape();
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.resetPendingEscape();
|
||||
}, []);
|
||||
|
||||
const suppressOutputTriggerScanAfterDroppedTerminalState = useCallback(() => {
|
||||
resetTriggerScanState();
|
||||
outputTriggerScanSuppressedRef.current = true;
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.reset();
|
||||
}, [resetTriggerScanState]);
|
||||
|
||||
const applyOutputTriggerMeta = useCallback((meta?: OutputTriggerAppendMeta) => {
|
||||
if (!meta) return;
|
||||
if (meta.droppedOutputAlternateScreenAction === 'leave') {
|
||||
restoreOutputTriggerScanAfterDroppedLeave();
|
||||
return;
|
||||
}
|
||||
if (meta.droppedOutputAlternateScreenAction === 'enter' || meta.droppedOutputMayAffectTerminalState) {
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
suppressOutputTriggerScanAfterDroppedTerminalState();
|
||||
}
|
||||
}, [restoreOutputTriggerScanAfterDroppedLeave, suppressOutputTriggerScanAfterDroppedTerminalState]);
|
||||
|
||||
const recoverOutputTriggerScanIfLeave = useCallback((
|
||||
result: TerminalOutputTriggerFilterResult,
|
||||
resetServerFilter = true,
|
||||
) => {
|
||||
if (result.meta.alternateScreenAction !== 'leave' || result.alternateScreenActive) {
|
||||
return false;
|
||||
}
|
||||
restoreOutputTriggerScanAfterDroppedLeave(resetServerFilter);
|
||||
return true;
|
||||
}, [restoreOutputTriggerScanAfterDroppedLeave]);
|
||||
|
||||
const processOutputTriggerOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
|
||||
applyOutputTriggerMeta(meta);
|
||||
applyPendingDroppedOverflowFinalAction();
|
||||
applyPendingDroppedOverflowScanStateReset();
|
||||
const { scannableText, alternateScreenActive } = serverOutputFilterRef.current.processServerChunk(chunk);
|
||||
if (outputTriggerScanSuppressedRef.current) {
|
||||
const recovery = outputTriggerSuppressionRecoveryFilterRef.current.processServerChunk(chunk);
|
||||
recoverOutputTriggerScanIfLeave(recovery, false);
|
||||
return;
|
||||
}
|
||||
if (!scannableText || alternateScreenActive) {
|
||||
return;
|
||||
}
|
||||
bufferRef.current = (bufferRef.current + scannableText).slice(-8192);
|
||||
scanBuffer(scannableText);
|
||||
}, [scanBuffer]);
|
||||
scanOutputRef.current(scannableText);
|
||||
}, [
|
||||
applyOutputTriggerMeta,
|
||||
applyPendingDroppedOverflowFinalAction,
|
||||
applyPendingDroppedOverflowScanStateReset,
|
||||
recoverOutputTriggerScanIfLeave,
|
||||
]);
|
||||
|
||||
const noteUserInput = useCallback((data: string) => {
|
||||
if (!data) return;
|
||||
const processDroppedOutputTriggerOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
|
||||
applyOutputTriggerMeta(meta);
|
||||
resetTriggerScanState();
|
||||
if (!chunk) return;
|
||||
serverOutputFilterRef.current.processServerChunk(chunk);
|
||||
if (outputTriggerScanSuppressedRef.current) {
|
||||
const recovery = outputTriggerSuppressionRecoveryFilterRef.current.processServerChunk(chunk);
|
||||
recoverOutputTriggerScanIfLeave(recovery, false);
|
||||
}
|
||||
}, [applyOutputTriggerMeta, recoverOutputTriggerScanIfLeave, resetTriggerScanState]);
|
||||
|
||||
const processOutputTriggerInput = useCallback((data: string) => {
|
||||
serverOutputFilterRef.current.noteUserInput(data);
|
||||
}, []);
|
||||
|
||||
const suppressOutputTriggerScanAfterDroppedOverflow = useCallback((overflow: {
|
||||
retainedPrefix: string;
|
||||
discardedSuffix: string;
|
||||
}) => {
|
||||
resetTriggerScanState();
|
||||
const inspection = inspectDroppedOutputOverflowAlternateScreenState(overflow);
|
||||
if (inspection.mayAffectScanState) {
|
||||
pendingDroppedOverflowScanStateResetRef.current = true;
|
||||
}
|
||||
if (!inspection.mayAffectAlternateScreen) {
|
||||
return;
|
||||
}
|
||||
if (inspection.finalAction === 'leave') {
|
||||
pendingDroppedOverflowFinalActionRef.current = 'leave';
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.reset();
|
||||
return;
|
||||
}
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
outputTriggerScanSuppressedRef.current = true;
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.reset();
|
||||
}, [resetTriggerScanState]);
|
||||
|
||||
const outputTriggerEventProcessor = useMemo(() => createDeferredOutputTriggerEventProcessor({
|
||||
processOutput: processOutputTriggerOutput,
|
||||
processDroppedOutput: processDroppedOutputTriggerOutput,
|
||||
processDroppedOutputOverflow: suppressOutputTriggerScanAfterDroppedOverflow,
|
||||
processInput: processOutputTriggerInput,
|
||||
processInputOverflow: (lineCount) => {
|
||||
serverOutputFilterRef.current.markInputEchoUncertain(lineCount);
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.markInputEchoUncertain(lineCount);
|
||||
resetTriggerScanState();
|
||||
},
|
||||
schedule: (callback) => {
|
||||
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
},
|
||||
}), [
|
||||
processDroppedOutputTriggerOutput,
|
||||
processOutputTriggerInput,
|
||||
processOutputTriggerOutput,
|
||||
resetTriggerScanState,
|
||||
suppressOutputTriggerScanAfterDroppedOverflow,
|
||||
]);
|
||||
|
||||
const appendOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
|
||||
if (!hasOutputTriggers || !chunk) return;
|
||||
outputTriggerEventProcessor.enqueueOutput(chunk, meta);
|
||||
}, [hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
const noteUserInput = useCallback((data: string) => {
|
||||
if (!hasOutputTriggers || !data) return;
|
||||
outputTriggerEventProcessor.enqueueInput(data);
|
||||
}, [hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => {
|
||||
bufferRef.current = '';
|
||||
outputTriggerEventProcessor.reset();
|
||||
scanBufferRef.current.reset();
|
||||
launchingRef.current = false;
|
||||
lastTriggerMatchEndRef.current = new Map();
|
||||
serverOutputFilterRef.current.reset();
|
||||
}, [sessionId, hostId]);
|
||||
outputTriggerSuppressionRecoveryFilterRef.current.reset();
|
||||
outputTriggerScanSuppressedRef.current = false;
|
||||
pendingDroppedOverflowFinalActionRef.current = null;
|
||||
pendingDroppedOverflowScanStateResetRef.current = false;
|
||||
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
|
||||
|
||||
useEffect(() => () => {
|
||||
outputTriggerEventProcessor.reset();
|
||||
}, [outputTriggerEventProcessor]);
|
||||
|
||||
return { appendOutput, noteUserInput };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,79 @@ import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
||||
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
||||
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
workspaces: Workspace[],
|
||||
workspace: Workspace,
|
||||
): Workspace[] {
|
||||
return workspaces.some(ws => ws.id === workspace.id) ? workspaces : [...workspaces, workspace];
|
||||
}
|
||||
|
||||
export function addTerminalSessionIfMissing(
|
||||
sessions: TerminalSession[],
|
||||
session: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
return sessions.some(candidate => candidate.id === session.id) ? sessions : [...sessions, session];
|
||||
}
|
||||
|
||||
export function insertWorkspacePaneIfMissing(
|
||||
workspaces: Workspace[],
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
hint: SplitHint,
|
||||
): Workspace[] {
|
||||
return workspaces.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
if (collectSessionIds(ws.root).includes(sessionId)) return ws;
|
||||
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
|
||||
});
|
||||
}
|
||||
|
||||
export function appendWorkspaceRootPaneIfMissing(
|
||||
workspaces: Workspace[],
|
||||
workspaceId: string,
|
||||
sessionId: string,
|
||||
direction: SplitDirection = 'vertical',
|
||||
): Workspace[] {
|
||||
return workspaces.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
if (collectSessionIds(ws.root).includes(sessionId)) {
|
||||
return ws.focusedSessionId === sessionId ? ws : { ...ws, focusedSessionId: sessionId };
|
||||
}
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, sessionId, direction),
|
||||
focusedSessionId: sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function insertCopiedTabOrderIdOnce(
|
||||
prevTabOrder: string[],
|
||||
sourceTabId: string,
|
||||
copiedTabId: string,
|
||||
allTabIds: string[],
|
||||
): string[] {
|
||||
if (prevTabOrder.includes(copiedTabId)) return prevTabOrder;
|
||||
|
||||
const directIdx = prevTabOrder.indexOf(sourceTabId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, copiedTabId);
|
||||
return next;
|
||||
}
|
||||
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
const sourceIdx = currentOrder.indexOf(sourceTabId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, copiedTabId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, copiedTabId);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
export const useSessionState = ({
|
||||
persistSessionRestore = true,
|
||||
@@ -634,14 +707,14 @@ export const useSessionState = ({
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const base = prevSessions.find(s => s.id === baseSessionId);
|
||||
const joining = prevSessions.find(s => s.id === joiningSessionId);
|
||||
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
|
||||
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
|
||||
setActiveTabId(newWorkspace.id);
|
||||
|
||||
return prevSessions.map(s => {
|
||||
@@ -668,10 +741,7 @@ export const useSessionState = ({
|
||||
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
|
||||
if (!targetWorkspace) return prevWorkspaces;
|
||||
|
||||
return prevWorkspaces.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
|
||||
});
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
|
||||
});
|
||||
|
||||
setActiveTabId(workspaceId);
|
||||
@@ -726,16 +796,9 @@ export const useSessionState = ({
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setSessions(s => addTerminalSessionIfMissing(s, newSession));
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
@@ -778,16 +841,9 @@ export const useSessionState = ({
|
||||
setWorkspaces(prev => {
|
||||
const target = prev.find(w => w.id === workspaceId);
|
||||
if (!target) return prev;
|
||||
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
|
||||
setSessions(s => addTerminalSessionIfMissing(s, newSession));
|
||||
setActiveTabId(workspaceId);
|
||||
return prev.map(ws => {
|
||||
if (ws.id !== workspaceId) return ws;
|
||||
return {
|
||||
...ws,
|
||||
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
|
||||
focusedSessionId: newSessionId,
|
||||
};
|
||||
});
|
||||
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
|
||||
});
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
@@ -808,6 +864,13 @@ export const useSessionState = ({
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
},
|
||||
) => {
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const standaloneHint: SplitHint = {
|
||||
direction,
|
||||
position: direction === 'horizontal' ? 'bottom' : 'right',
|
||||
};
|
||||
const standaloneWorkspace = createWorkspaceEntity(sessionId, newSessionId, standaloneHint);
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
if (!session) return prevSessions;
|
||||
@@ -816,7 +879,7 @@ export const useSessionState = ({
|
||||
if (session.workspaceId) {
|
||||
// Create a new session with the same host
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
workspaceId: session.workspaceId,
|
||||
});
|
||||
@@ -829,36 +892,27 @@ export const useSessionState = ({
|
||||
};
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
return prevWorkspaces.map(ws => {
|
||||
if (ws.id !== session.workspaceId) return ws;
|
||||
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
|
||||
});
|
||||
return insertWorkspacePaneIfMissing(prevWorkspaces, session.workspaceId, newSession.id, hint);
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
return addTerminalSessionIfMissing(prevSessions, newSession);
|
||||
}
|
||||
|
||||
// Session is standalone - create a new workspace
|
||||
const newSession = createSplitTerminalSessionClone(session, {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
position: direction === 'horizontal' ? 'bottom' : 'right',
|
||||
};
|
||||
|
||||
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setActiveTabId(newWorkspace.id);
|
||||
setWorkspaces(prev => addWorkspaceIfMissing(prev, standaloneWorkspace));
|
||||
setActiveTabId(standaloneWorkspace.id);
|
||||
|
||||
return prevSessions.map(s => {
|
||||
if (s.id === sessionId) {
|
||||
return { ...s, workspaceId: newWorkspace.id };
|
||||
return { ...s, workspaceId: standaloneWorkspace.id };
|
||||
}
|
||||
return s;
|
||||
}).concat({ ...newSession, workspaceId: newWorkspace.id });
|
||||
}).concat({ ...newSession, workspaceId: standaloneWorkspace.id });
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
@@ -973,6 +1027,7 @@ export const useSessionState = ({
|
||||
: {
|
||||
startupCommand: resolvedCommand,
|
||||
noAutoRun: snippet.noAutoRun,
|
||||
multiLineRunMode: snippet.multiLineRunMode ?? 'paste',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1023,30 +1078,12 @@ export const useSessionState = ({
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Fast path: source is already tracked in tabOrder — splice directly.
|
||||
const directIdx = prevTabOrder.indexOf(sessionId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
}
|
||||
// Fallback: source is only in the derived tab collections. Rebuild the
|
||||
// effective order (same pattern as reorderTabs) to locate its position.
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
const sourceIdx = currentOrder.indexOf(sessionId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { collectSessionIds, type SplitHint } from '../../domain/workspace';
|
||||
import {
|
||||
addTerminalSessionIfMissing,
|
||||
addWorkspaceIfMissing,
|
||||
appendWorkspaceRootPaneIfMissing,
|
||||
insertCopiedTabOrderIdOnce,
|
||||
insertWorkspacePaneIfMissing,
|
||||
} from './useSessionState';
|
||||
|
||||
const session = (id: string, workspaceId?: string): TerminalSession => ({
|
||||
id,
|
||||
hostId: `host-${id}`,
|
||||
hostLabel: `Host ${id}`,
|
||||
hostname: `${id}.example.test`,
|
||||
username: 'user',
|
||||
status: 'connecting',
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (id = 'ws-1'): Workspace => ({
|
||||
id,
|
||||
title: 'Workspace',
|
||||
focusedSessionId: 's1',
|
||||
root: { id: 'pane-1', type: 'pane', sessionId: 's1' },
|
||||
});
|
||||
|
||||
const paneCount = (ws: Workspace, sessionId: string) => (
|
||||
collectSessionIds(ws.root).filter(id => id === sessionId).length
|
||||
);
|
||||
|
||||
test('workspace creation remains idempotent when the same update runs twice', () => {
|
||||
const ws = workspace();
|
||||
|
||||
const once = addWorkspaceIfMissing([], ws);
|
||||
const twice = addWorkspaceIfMissing(once, ws);
|
||||
|
||||
assert.equal(twice.length, 1);
|
||||
assert.equal(twice[0], ws);
|
||||
});
|
||||
|
||||
test('terminal session insertion remains idempotent when the same update runs twice', () => {
|
||||
const newSession = session('s2', 'ws-1');
|
||||
|
||||
const once = addTerminalSessionIfMissing([session('s1', 'ws-1')], newSession);
|
||||
const twice = addTerminalSessionIfMissing(once, newSession);
|
||||
|
||||
assert.equal(twice.filter(candidate => candidate.id === 's2').length, 1);
|
||||
});
|
||||
|
||||
test('workspace pane insertion remains idempotent when the same update runs twice', () => {
|
||||
const hint: SplitHint = {
|
||||
direction: 'vertical',
|
||||
position: 'right',
|
||||
targetSessionId: 's1',
|
||||
};
|
||||
|
||||
const once = insertWorkspacePaneIfMissing([workspace()], 'ws-1', 's2', hint);
|
||||
const twice = insertWorkspacePaneIfMissing(once, 'ws-1', 's2', hint);
|
||||
|
||||
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
|
||||
assert.equal(paneCount(twice[0], 's2'), 1);
|
||||
});
|
||||
|
||||
test('workspace root append remains idempotent when the same update runs twice', () => {
|
||||
const once = appendWorkspaceRootPaneIfMissing([workspace()], 'ws-1', 's2', 'vertical');
|
||||
const twice = appendWorkspaceRootPaneIfMissing(once, 'ws-1', 's2', 'vertical');
|
||||
|
||||
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
|
||||
assert.equal(paneCount(twice[0], 's2'), 1);
|
||||
assert.equal(twice[0].focusedSessionId, 's2');
|
||||
});
|
||||
|
||||
test('copied tab order insertion remains idempotent when the same update runs twice', () => {
|
||||
const once = insertCopiedTabOrderIdOnce(['s1', 'ws-1'], 's1', 's2', ['s1', 'ws-1']);
|
||||
const twice = insertCopiedTabOrderIdOnce(once, 's1', 's2', ['s1', 'ws-1']);
|
||||
|
||||
assert.deepEqual(twice, ['s1', 's2', 'ws-1']);
|
||||
assert.equal(twice.filter(id => id === 's2').length, 1);
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
@@ -50,6 +51,8 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -96,6 +99,7 @@ import {
|
||||
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_SSH_DEEP_LINK_ENABLED,
|
||||
DEFAULT_JMS_DEEP_LINK_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
@@ -122,6 +126,12 @@ import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import { DEFAULT_APP_ICON_VARIANT } from '../../infrastructure/config/appIconVariants';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED,
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
isTerminalSidePanelAutoOpenTab,
|
||||
type TerminalSidePanelAutoOpenTab,
|
||||
} from '../../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
|
||||
const enableSettingsSync = options.enableSettingsSync !== false;
|
||||
@@ -253,6 +263,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpenState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
return stored ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED;
|
||||
});
|
||||
const [terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
|
||||
});
|
||||
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
@@ -306,6 +324,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
|
||||
});
|
||||
const [jmsDeepLinkEnabled, setJmsDeepLinkEnabledState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
@@ -367,6 +389,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
|
||||
const sshDeepLinkSetRequestIdRef = useRef(0);
|
||||
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
|
||||
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
|
||||
const jmsDeepLinkSetRequestIdRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
@@ -460,6 +485,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyIncomingJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
jmsDeepLinkSetRequestIdRef.current += 1;
|
||||
setJmsDeepLinkEnabledState((prev) => {
|
||||
if (prev === enabled) return prev;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
return enabled;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
if (!enableSettingsSync) return;
|
||||
@@ -582,6 +616,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
|
||||
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
|
||||
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
|
||||
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
@@ -608,6 +644,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
setTerminalSidePanelAutoOpenState(storedTerminalSidePanelAutoOpen ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED);
|
||||
const storedTerminalSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
setTerminalSidePanelAutoOpenTabState(
|
||||
isTerminalSidePanelAutoOpenTab(storedTerminalSidePanelAutoOpenTab)
|
||||
? storedTerminalSidePanelAutoOpenTab
|
||||
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
|
||||
@@ -623,7 +667,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
@@ -700,6 +744,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
@@ -712,6 +757,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setDisableTerminalFontZoomState,
|
||||
setRestorePreviousSessionState,
|
||||
setRestoreTerminalCwdState,
|
||||
@@ -743,8 +790,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -752,8 +799,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -866,6 +913,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTerminalSidePanelAutoOpen = useCallback((enabled: boolean) => {
|
||||
setTerminalSidePanelAutoOpenState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTerminalSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
|
||||
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
|
||||
setTerminalSidePanelAutoOpenTabState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
|
||||
setShellOnlyTabNumberShortcutsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
@@ -1046,6 +1108,66 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
|
||||
}, [sshDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
jmsDeepLinkEnabledRef.current = jmsDeepLinkEnabled;
|
||||
}, [jmsDeepLinkEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const requestIdAtStart = jmsDeepLinkSetRequestIdRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getJmsDeepLinkEnabled) return;
|
||||
void bridge.getJmsDeepLinkEnabled().then((enabled) => {
|
||||
if (cancelled || typeof enabled !== 'boolean') return;
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, enabled);
|
||||
}).catch(() => {
|
||||
// The renderer can still use its cached setting when the bridge is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
|
||||
const previous = jmsDeepLinkEnabledRef.current;
|
||||
const requestId = jmsDeepLinkSetRequestIdRef.current + 1;
|
||||
jmsDeepLinkSetRequestIdRef.current = requestId;
|
||||
jmsDeepLinkMutationSourceRef.current = 'local';
|
||||
setJmsDeepLinkEnabledState(enabled);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setJmsDeepLinkEnabled) return;
|
||||
void bridge.setJmsDeepLinkEnabled(enabled).then((result) => {
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
const success = typeof result === 'object' ? result.success : result;
|
||||
if (success !== false) return;
|
||||
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
|
||||
? result.enabled
|
||||
: previous;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState(finalEnabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, finalEnabled);
|
||||
}).catch(() => {
|
||||
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
|
||||
jmsDeepLinkMutationSourceRef.current = 'incoming';
|
||||
setJmsDeepLinkEnabledState(previous);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, previous);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
|
||||
if (jmsDeepLinkMutationSourceRef.current === 'incoming') {
|
||||
jmsDeepLinkMutationSourceRef.current = 'local';
|
||||
return;
|
||||
}
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
|
||||
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
|
||||
|
||||
useSystemSettingsEffects({
|
||||
enabled: enableSystemEffects,
|
||||
toggleWindowHotkey,
|
||||
@@ -1203,6 +1325,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
terminalSidePanelAutoOpen,
|
||||
setTerminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
disableTerminalFontZoom,
|
||||
@@ -1233,6 +1359,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshDebugLogsEnabled,
|
||||
sshDeepLinkEnabled,
|
||||
setSshDeepLinkEnabled,
|
||||
jmsDeepLinkEnabled,
|
||||
setJmsDeepLinkEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
@@ -1259,8 +1387,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -124,7 +124,7 @@ export const useTerminalBackend = () => {
|
||||
|
||||
const onSessionData = useCallback((
|
||||
sessionId: string,
|
||||
cb: (data: string) => void,
|
||||
cb: Parameters<NetcattyBridge["onSessionData"]>[1],
|
||||
options?: Parameters<NetcattyBridge["onSessionData"]>[2],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -168,6 +168,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onWindowFullScreenChanged?.(cb);
|
||||
}, []);
|
||||
|
||||
const onWindowShown = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowShown?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -370,6 +375,7 @@ export const useTerminalBackend = () => {
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -422,6 +428,7 @@ export const useTerminalBackend = () => {
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onWindowShown,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Host, TerminalTheme } from '../../domain/models';
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
import {
|
||||
idleThemeUserIntent,
|
||||
pickingThemeUserIntent,
|
||||
@@ -113,7 +113,3 @@ export function useTerminalAppearanceInjection(
|
||||
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
|
||||
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
|
||||
}
|
||||
|
||||
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
|
||||
return { host, isEphemeral };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as storageKeys from "../../infrastructure/config/storageKeys.ts";
|
||||
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "./ymodemFileMemory.ts";
|
||||
|
||||
type TestStore = {
|
||||
readString: (key: string) => string | null;
|
||||
writeString: (key: string, value: string) => boolean;
|
||||
};
|
||||
|
||||
const createStore = (initial: Record<string, string> = {}): {
|
||||
store: TestStore;
|
||||
values: Map<string, string>;
|
||||
} => {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
store: {
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
writeString: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
test("uses the remembered YMODEM send directory as the file picker default path", () => {
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR,
|
||||
"netcatty_terminal_ymodem_send_dir_v1",
|
||||
);
|
||||
|
||||
const { store } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases");
|
||||
});
|
||||
|
||||
test("preserves valid remembered directory whitespace", () => {
|
||||
const { store } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases ",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases ");
|
||||
});
|
||||
|
||||
test("remembers the directory containing a selected YMODEM send file", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("/firmware/releases/device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"/firmware/releases",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps POSIX backslashes as part of the selected file name", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("/firmware/releases\\device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"/firmware",
|
||||
);
|
||||
});
|
||||
|
||||
test("remembers Windows-style YMODEM send directories", () => {
|
||||
const { store, values } = createStore();
|
||||
|
||||
assert.equal(rememberYmodemSendFilePath("C:\\Users\\catty\\device.bin", store), true);
|
||||
assert.equal(
|
||||
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
|
||||
"C:\\Users\\catty",
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores blank remembered paths and selected file names without a parent directory", () => {
|
||||
const { store, values } = createStore({
|
||||
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: " ",
|
||||
});
|
||||
|
||||
assert.equal(getRememberedYmodemSendDefaultPath(store), undefined);
|
||||
assert.equal(rememberYmodemSendFilePath("device.bin", store), false);
|
||||
assert.equal(values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR), " ");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type YmodemFileMemoryStore = {
|
||||
readString: (key: string) => string | null;
|
||||
writeString: (key: string, value: string) => boolean;
|
||||
};
|
||||
|
||||
const getParentDirectory = (filePath: string): string | null => {
|
||||
const lastForwardSlash = filePath.lastIndexOf("/");
|
||||
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
|
||||
const lastSeparator = isWindowsPath
|
||||
? Math.max(lastForwardSlash, filePath.lastIndexOf("\\"))
|
||||
: lastForwardSlash;
|
||||
|
||||
if (lastSeparator < 0) return null;
|
||||
if (lastSeparator === 0) return filePath.slice(0, 1);
|
||||
if (lastSeparator === 2 && filePath[1] === ":") return filePath.slice(0, 3);
|
||||
|
||||
return filePath.slice(0, lastSeparator);
|
||||
};
|
||||
|
||||
export const getRememberedYmodemSendDefaultPath = (
|
||||
store: YmodemFileMemoryStore = localStorageAdapter,
|
||||
): string | undefined => {
|
||||
const rememberedPath = store.readString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR);
|
||||
if (!rememberedPath || rememberedPath.trim() === "") return undefined;
|
||||
return rememberedPath;
|
||||
};
|
||||
|
||||
export const rememberYmodemSendFilePath = (
|
||||
filePath: string,
|
||||
store: YmodemFileMemoryStore = localStorageAdapter,
|
||||
): boolean => {
|
||||
const directory = getParentDirectory(filePath);
|
||||
if (!directory) return false;
|
||||
return store.writeString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR, directory);
|
||||
};
|
||||
@@ -181,12 +181,29 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
||||
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
|
||||
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
@@ -378,6 +395,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
|
||||
customGroups: [],
|
||||
settings: {
|
||||
showHostTreeSidebar: false,
|
||||
terminalSidePanelAutoOpen: true,
|
||||
terminalSidePanelAutoOpenTab: "scripts",
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
@@ -385,6 +404,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -88,6 +90,7 @@ import {
|
||||
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -227,6 +230,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
@@ -386,6 +391,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
|
||||
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
|
||||
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
|
||||
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
|
||||
}
|
||||
|
||||
// Terminal settings (syncable subset only)
|
||||
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -571,6 +582,12 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
if (settings.terminalSidePanelAutoOpen != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
|
||||
}
|
||||
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
|
||||
}
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
|
||||
@@ -108,22 +108,6 @@ export function modelPresetsContainId(presets: AgentModelPreset[], modelId: stri
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
getExternalAgentSdkBackend(agent),
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
|
||||
// reduce to their basename rather than staying as the full path.
|
||||
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return sdkBackend === 'claude'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({}), false);
|
||||
});
|
||||
@@ -18,8 +18,7 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
EnvVar,
|
||||
@@ -54,6 +53,7 @@ import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -83,11 +83,17 @@ interface GroupDetailsPanelProps {
|
||||
|
||||
type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.telnetPort !== undefined ||
|
||||
c.telnetUsername !== undefined ||
|
||||
c.telnetPassword !== undefined ||
|
||||
c.telnetEnabled === true;
|
||||
|
||||
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
groupPath,
|
||||
config,
|
||||
availableKeys,
|
||||
identities: _identities,
|
||||
identities,
|
||||
proxyProfiles = [],
|
||||
allHosts,
|
||||
groups,
|
||||
@@ -129,16 +135,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
c.etEnabled !== undefined || c.etPort !== undefined ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
const hasTelnetFields = (c: Partial<GroupConfig>) =>
|
||||
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
|
||||
|
||||
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
|
||||
const [telnetEnabled, setTelnetEnabled] = useState(() => hasTelnetFields(config || {}));
|
||||
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
|
||||
|
||||
// Sub-panel state
|
||||
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
|
||||
@@ -191,6 +195,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.deviceType;
|
||||
delete next.agentForwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.skipEcdsaHostKey;
|
||||
delete next.algorithms;
|
||||
@@ -223,18 +228,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
// Proxy helpers
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -367,6 +366,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedStartupCommandRunMode = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return "paste";
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedDeviceType = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return undefined;
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
|
||||
@@ -385,19 +388,31 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNameError(t("vault.groups.errors.invalidChars"));
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (sshEnabled && hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
const proxySave = sshEnabled
|
||||
? prepareProxyConfigForSave({
|
||||
proxyConfig: form.proxyConfig,
|
||||
proxyProfileId: form.proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
})
|
||||
: null;
|
||||
if (proxySave?.error) {
|
||||
const messageKey = proxySave.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: proxySave.error === "required"
|
||||
? "hostDetails.proxyPanel.error.required"
|
||||
: proxySave.error === "missingSaved"
|
||||
? "hostDetails.proxyPanel.missingSaved"
|
||||
: proxySave.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: proxySave.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: "hostDetails.proxyPanel.unreadableIdentity";
|
||||
toast.error(t(messageKey));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = proxySave?.normalizedProxyConfig;
|
||||
setNameError(null);
|
||||
|
||||
const newPath = parentGroup
|
||||
@@ -420,6 +435,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.skipEcdsaHostKey !== undefined && { skipEcdsaHostKey: form.skipEcdsaHostKey }),
|
||||
...(form.algorithms !== undefined && { algorithms: form.algorithms }),
|
||||
@@ -468,6 +484,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
@@ -622,6 +639,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
inheritedAlgorithmOverrides={inheritedAlgorithmOverrides}
|
||||
@@ -700,7 +718,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
value={form.telnetUsername || ""}
|
||||
onChange={(e) => update("telnetUsername", e.target.value || undefined)}
|
||||
onChange={(e) => update("telnetUsername", e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
@@ -708,7 +726,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
placeholder={t("hostDetails.password.placeholder")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={form.telnetPassword || ""}
|
||||
onChange={(e) => update("telnetPassword", e.target.value || undefined)}
|
||||
onChange={(e) => update("telnetPassword", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -43,6 +43,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
inheritedAlgorithmOverrides,
|
||||
@@ -51,6 +52,8 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
chainedHosts,
|
||||
}) => {
|
||||
if (!sshEnabled) return null;
|
||||
const startupCommandRunModeFallback = inheritedStartupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? startupCommandRunModeFallback;
|
||||
|
||||
return (
|
||||
<HostDetailsSection
|
||||
@@ -384,6 +387,26 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.startupCommand.runMode")}
|
||||
hint={t("hostDetails.startupCommand.runMode.help")}
|
||||
>
|
||||
<Select
|
||||
value={effectiveStartupCommandRunMode}
|
||||
onValueChange={(value) => update(
|
||||
"startupCommandRunMode",
|
||||
value === startupCommandRunModeFallback ? undefined : value,
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
|
||||
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
{/* Display the *effective* value (this group's field falling
|
||||
back to the resolved parent default). Same rationale as
|
||||
|
||||
@@ -45,10 +45,11 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
proxySummaryLabel,
|
||||
proxySummaryTooltip,
|
||||
clearProxyConfig,
|
||||
groupDefaults,
|
||||
}) => {
|
||||
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
|
||||
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
|
||||
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
|
||||
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -621,12 +622,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
hint={t("hostDetails.startupCommand.help")}
|
||||
>
|
||||
<Textarea
|
||||
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
placeholder={effectiveGroupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.startupCommand.runMode")}
|
||||
hint={t("hostDetails.startupCommand.runMode.help")}
|
||||
>
|
||||
<Select
|
||||
value={effectiveStartupCommandRunMode}
|
||||
onValueChange={(value) => update(
|
||||
"startupCommandRunMode",
|
||||
value === inheritedStartupCommandRunMode ? undefined : value,
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
|
||||
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</HostDetailsSettingRow>
|
||||
</HostDetailsSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import type { Host } from "../types";
|
||||
import {
|
||||
findProxyProfile,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
} from "../domain/proxyProfiles";
|
||||
import type { Host, Identity, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
@@ -78,8 +86,74 @@ export const resolveDetailsTelnetPassword = (
|
||||
? groupDefaults.telnetPassword
|
||||
: host.password ?? groupDefaults?.password ?? "";
|
||||
|
||||
export const prepareTelnetCredentialsForSave = (host: Host): Host =>
|
||||
host.telnetIdentityId
|
||||
? {
|
||||
...host,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
}
|
||||
: host;
|
||||
|
||||
export const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...POSIX_PLATFORM_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
|
||||
export type ProxySaveValidationError =
|
||||
| "required"
|
||||
| "port"
|
||||
| "missingSaved"
|
||||
| "missingIdentity"
|
||||
| "incompleteIdentity"
|
||||
| "unreadableIdentity";
|
||||
|
||||
export const validateProxyConfigForSave = ({
|
||||
proxyConfig,
|
||||
proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
}: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): ProxySaveValidationError | null => {
|
||||
const selectedProxyProfile = findProxyProfile(proxyProfileId, proxyProfiles ?? []);
|
||||
if (proxyProfileId && !selectedProxyProfile) return "missingSaved";
|
||||
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
return normalizedProxyConfig.host ? "port" : "required";
|
||||
}
|
||||
|
||||
const effectiveProxyConfig = selectedProxyProfile?.config ?? proxyConfig;
|
||||
if (hasMissingProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "missingIdentity";
|
||||
}
|
||||
if (hasIncompleteProxyIdentity(effectiveProxyConfig, identities ?? [])) {
|
||||
return "incompleteIdentity";
|
||||
}
|
||||
if (hasUnreadableProxyCredential(effectiveProxyConfig, identities ?? [])) {
|
||||
return "unreadableIdentity";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const prepareProxyConfigForSave = (params: {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfileId?: string;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
}): {
|
||||
error?: ProxySaveValidationError;
|
||||
normalizedProxyConfig?: ProxyConfig;
|
||||
} => {
|
||||
const error = validateProxyConfigForSave(params);
|
||||
if (error) return { error };
|
||||
return {
|
||||
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
|
||||
};
|
||||
};
|
||||
@@ -4,11 +4,14 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import type { Host, Identity } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import {
|
||||
prepareProxyConfigForSave,
|
||||
prepareTelnetCredentialsForSave,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
validateProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers.ts";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
@@ -26,7 +29,10 @@ const hostWithMissingProxyProfile: Host = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
const renderHostDetails = (
|
||||
initialData: Host = hostWithMissingProxyProfile,
|
||||
options: { identities?: Identity[] } = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
@@ -37,7 +43,7 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
identities: options.identities ?? [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
@@ -108,6 +114,60 @@ test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () =>
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel shows a selected telnet identity instead of manual telnet fields", () => {
|
||||
const markup = renderHostDetails(
|
||||
{
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
{
|
||||
identities: [{
|
||||
id: "ssh-identity",
|
||||
label: "SSH login",
|
||||
username: "ssh-user",
|
||||
authMethod: "password",
|
||||
password: "ssh-password",
|
||||
created: 1,
|
||||
}, {
|
||||
id: "telnet-identity",
|
||||
label: "Telnet login",
|
||||
username: "telnet-user",
|
||||
authMethod: "password",
|
||||
password: "telnet-password",
|
||||
created: 2,
|
||||
}],
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(markup, /telnet-user - Telnet login/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Username"/);
|
||||
assert.doesNotMatch(markup, /placeholder="Telnet Password"/);
|
||||
});
|
||||
|
||||
test("prepareTelnetCredentialsForSave preserves selected telnet identity and clears manual telnet fields", () => {
|
||||
const saved = prepareTelnetCredentialsForSave({
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
identityId: "ssh-identity",
|
||||
telnetIdentityId: "telnet-identity",
|
||||
telnetUsername: "stale-telnet-user",
|
||||
telnetPassword: "stale-telnet-password",
|
||||
proxyProfileId: undefined,
|
||||
});
|
||||
|
||||
assert.equal(saved.identityId, "ssh-identity");
|
||||
assert.equal(saved.telnetIdentityId, "telnet-identity");
|
||||
assert.equal(saved.telnetUsername, undefined);
|
||||
assert.equal(saved.telnetPassword, undefined);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel disables save when hostname is whitespace only", () => {
|
||||
const markup = renderHostDetails({
|
||||
...hostWithMissingProxyProfile,
|
||||
@@ -275,6 +335,138 @@ test("parseOptionalPortInput clears empty port values", () => {
|
||||
assert.equal(parseOptionalPortInput("2325"), 2325);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects missing identities inside saved proxy profiles", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [],
|
||||
}),
|
||||
"missingIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects incomplete proxy identities", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"incompleteIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProxyConfigForSave rejects unreadable proxy identity passwords", () => {
|
||||
assert.equal(
|
||||
validateProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
"unreadableIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns a normalized proxy config for save handlers", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: " proxy.example.com ",
|
||||
port: "3128" as never,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
username: " proxy-user ",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
identities: [],
|
||||
}),
|
||||
{
|
||||
normalizedProxyConfig: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyConfigForSave returns identity errors before save handlers build output", () => {
|
||||
assert.deepEqual(
|
||||
prepareProxyConfigForSave({
|
||||
proxyProfileId: "profile-1",
|
||||
proxyProfiles: [{
|
||||
id: "profile-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "identity-1",
|
||||
},
|
||||
createdAt: 1,
|
||||
}],
|
||||
identities: [{
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
{ error: "incompleteIdentity" },
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
|
||||
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
|
||||
|
||||
+100
-47
@@ -20,8 +20,7 @@ import {
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
@@ -50,6 +49,8 @@ import {
|
||||
resolveDetailsTelnetUsername,
|
||||
resolvePrimaryProtocolSavePort,
|
||||
resolvePrimaryProtocolSwitchPort,
|
||||
prepareTelnetCredentialsForSave,
|
||||
prepareProxyConfigForSave,
|
||||
} from "./HostDetailsPanel.helpers";
|
||||
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -327,18 +328,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}, []);
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
@@ -408,19 +403,29 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const handleSubmit = () => {
|
||||
const hostname = form.hostname.trim();
|
||||
if (!hostname) return;
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
const proxySave = prepareProxyConfigForSave({
|
||||
proxyConfig: form.proxyConfig,
|
||||
proxyProfileId: form.proxyProfileId,
|
||||
proxyProfiles,
|
||||
identities,
|
||||
});
|
||||
if (proxySave.error) {
|
||||
const messageKey = proxySave.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: proxySave.error === "required"
|
||||
? "hostDetails.proxyPanel.error.required"
|
||||
: proxySave.error === "missingSaved"
|
||||
? "hostDetails.proxyPanel.missingSaved"
|
||||
: proxySave.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: proxySave.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: "hostDetails.proxyPanel.unreadableIdentity";
|
||||
toast.error(t(messageKey));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = proxySave.normalizedProxyConfig;
|
||||
let finalLabel = form.label?.trim() || hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
|
||||
@@ -462,7 +467,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
cleaned = normalizePrimaryTelnetState(cleaned);
|
||||
cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned));
|
||||
if (
|
||||
onImportKey &&
|
||||
pendingReferenceKeyPath &&
|
||||
@@ -594,6 +599,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
return identities.find((i) => i.id === form.identityId);
|
||||
}, [form.identityId, identities]);
|
||||
|
||||
const selectedTelnetIdentity = useMemo(() => {
|
||||
if (!form.telnetIdentityId) return undefined;
|
||||
return identities.find((i) => i.id === form.telnetIdentityId);
|
||||
}, [form.telnetIdentityId, identities]);
|
||||
|
||||
const telnetIdentityOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
identities.map((identity) => ({
|
||||
value: identity.id,
|
||||
label: identity.label,
|
||||
sublabel: identity.username,
|
||||
})),
|
||||
[identities],
|
||||
);
|
||||
|
||||
const filteredIdentitySuggestions = useMemo(() => {
|
||||
if (selectedIdentity) return [];
|
||||
const q = (form.username || "").toLowerCase().trim();
|
||||
@@ -639,6 +659,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
setIdentitySuggestionsOpen(false);
|
||||
}, []);
|
||||
|
||||
const updateTelnetIdentity = useCallback((identityId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
telnetIdentityId: identityId || undefined,
|
||||
...(identityId
|
||||
? {
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (activeSubPanel === "create-group") {
|
||||
return (
|
||||
<CreateGroupPanel
|
||||
@@ -661,6 +694,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
@@ -954,7 +988,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
proxySummaryLabel={proxySummaryLabel}
|
||||
proxySummaryTooltip={proxySummaryTooltip}
|
||||
clearProxyConfig={clearProxyConfig}
|
||||
groupDefaults={groupDefaults}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
@@ -1014,32 +1047,52 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={effectiveTelnetUsername}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={effectiveTelnetPassword}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10 pr-10"
|
||||
{identities.length > 0 && (
|
||||
<Combobox
|
||||
options={telnetIdentityOptions}
|
||||
value={form.telnetIdentityId || ""}
|
||||
onValueChange={updateTelnetIdentity}
|
||||
placeholder={t("hostDetails.telnet.identity.placeholder")}
|
||||
emptyText={t("common.noResultsFound")}
|
||||
className="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{form.telnetIdentityId ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedTelnetIdentity
|
||||
? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}`
|
||||
: t("hostDetails.identity.missing")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.username")}
|
||||
value={effectiveTelnetUsername}
|
||||
onChange={(e) =>
|
||||
update("telnetUsername" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.telnet.password")}
|
||||
type={showTelnetPassword ? "text" : "password"}
|
||||
value={effectiveTelnetPassword}
|
||||
onChange={(e) =>
|
||||
update("telnetPassword" as keyof Host, e.target.value)
|
||||
}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Input
|
||||
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
@@ -115,6 +116,11 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
const { generateKeyPair, execCommand } = useKeychainBackend();
|
||||
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
|
||||
const [search, setSearch] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "key" | "identity";
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_KEYS_VIEW_MODE,
|
||||
"grid",
|
||||
@@ -468,24 +474,49 @@ echo $3 >> "$FILE"`);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(
|
||||
async (id: string) => {
|
||||
onDelete(id);
|
||||
if (panel.type === "view" && panel.key.id === id) {
|
||||
closePanel();
|
||||
}
|
||||
(id: string) => {
|
||||
const key = keys.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: "key",
|
||||
id,
|
||||
name: key?.label || t("keychain.panel.keyDetails"),
|
||||
});
|
||||
},
|
||||
[onDelete, panel, closePanel],
|
||||
[keys, t],
|
||||
);
|
||||
|
||||
// Handle delete identity
|
||||
const _handleDeleteIdentity = useCallback(
|
||||
(id: string) => {
|
||||
onDeleteIdentity?.(id);
|
||||
if (panel.type === "identity" && panel.identity?.id === id) {
|
||||
closePanel();
|
||||
}
|
||||
const identity = identities.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: "identity",
|
||||
id,
|
||||
name: identity?.label || t("keychain.panel.editIdentity"),
|
||||
});
|
||||
},
|
||||
[onDeleteIdentity, panel, closePanel],
|
||||
[identities, t],
|
||||
);
|
||||
|
||||
const confirmDeleteTarget = useCallback(
|
||||
() => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.type === "key") {
|
||||
onDelete(deleteTarget.id);
|
||||
if (panel.type === "view" && panel.key.id === deleteTarget.id) {
|
||||
closePanel();
|
||||
}
|
||||
} else {
|
||||
onDeleteIdentity?.(deleteTarget.id);
|
||||
if (panel.type === "identity" && panel.identity?.id === deleteTarget.id) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[closePanel, deleteTarget, onDelete, onDeleteIdentity, panel],
|
||||
);
|
||||
|
||||
// Get icon for key source
|
||||
@@ -837,15 +868,7 @@ echo $3 >> "$FILE"`);
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
t("confirm.deleteIdentity", {
|
||||
name: identity.label || "",
|
||||
}),
|
||||
);
|
||||
if (!ok) return;
|
||||
_handleDeleteIdentity(identity.id);
|
||||
}}
|
||||
onClick={() => _handleDeleteIdentity(identity.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />{" "}
|
||||
{t("action.delete")}
|
||||
@@ -876,13 +899,7 @@ echo $3 >> "$FILE"`);
|
||||
variant="destructive"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => {
|
||||
const ok = window.confirm(
|
||||
t("confirm.deleteIdentity", {
|
||||
name: panel.identity?.label || "",
|
||||
}),
|
||||
);
|
||||
if (!ok || !panel.identity) return;
|
||||
_handleDeleteIdentity(panel.identity.id);
|
||||
if (panel.identity) _handleDeleteIdentity(panel.identity.id);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
@@ -1031,6 +1048,18 @@ echo $3 >> "$FILE"`);
|
||||
)}
|
||||
</AsidePanel>
|
||||
)}
|
||||
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.name ?? "",
|
||||
})}
|
||||
description={t("vault.deleteConfirm.desc")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
@@ -294,6 +295,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const [search, setSearch] = useState("");
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
@@ -471,9 +473,23 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
// Memoized handlers to prevent re-renders
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
onDelete(id);
|
||||
setDeleteTargetId(id);
|
||||
},
|
||||
[onDelete],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteTarget = useMemo(
|
||||
() => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null,
|
||||
[deleteTargetId, knownHosts],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
() => {
|
||||
if (!deleteTargetId) return;
|
||||
onDelete(deleteTargetId);
|
||||
setDeleteTargetId(null);
|
||||
},
|
||||
[deleteTargetId, onDelete],
|
||||
);
|
||||
|
||||
const handleConvertToHost = useCallback(
|
||||
@@ -670,6 +686,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTargetId)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.hostname ?? "",
|
||||
})}
|
||||
description={t("vault.deleteConfirm.desc")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTargetId(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
@@ -32,14 +31,6 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { SortDropdown } from "./ui/sort-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -50,6 +41,7 @@ import {
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
@@ -436,33 +428,24 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
setSelectedRuleId(null);
|
||||
}, [setSelectedRuleId]);
|
||||
|
||||
// Handle delete with confirmation for active tunnels
|
||||
// Handle delete with confirmation
|
||||
const handleDeleteRule = useCallback(
|
||||
(rule: PortForwardingRule) => {
|
||||
// If tunnel is active or connecting, show confirmation dialog
|
||||
if (rule.status === "active" || rule.status === "connecting") {
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
} else {
|
||||
// If inactive, delete directly
|
||||
if (editingRule?.id === rule.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
deleteRule(rule.id);
|
||||
}
|
||||
setRuleToDelete(rule);
|
||||
setShowDeleteConfirm(true);
|
||||
},
|
||||
[editingRule, deleteRule, closeEditPanel],
|
||||
[],
|
||||
);
|
||||
|
||||
// Confirm delete of active tunnel: stop first, then delete
|
||||
const confirmDeleteActiveRule = useCallback(async () => {
|
||||
// Confirm delete; active tunnels are stopped first.
|
||||
const confirmDeleteRule = useCallback(async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Stop the tunnel first
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
// Then delete the rule
|
||||
if (ruleToDelete.status === "active" || ruleToDelete.status === "connecting") {
|
||||
await stopTunnel(ruleToDelete.id);
|
||||
}
|
||||
if (editingRule?.id === ruleToDelete.id) {
|
||||
closeEditPanel();
|
||||
}
|
||||
@@ -474,6 +457,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
}
|
||||
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
|
||||
|
||||
const deleteTargetIsActive = ruleToDelete?.status === "active" || ruleToDelete?.status === "connecting";
|
||||
|
||||
// Handle wizard navigation
|
||||
// Flow for local: type -> local-config -> destination -> host-selection
|
||||
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
|
||||
@@ -923,44 +908,26 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Active Tunnel Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
|
||||
if (!isDeleting) {
|
||||
<VaultDeleteConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: ruleToDelete?.label ?? "",
|
||||
})}
|
||||
description={
|
||||
deleteTargetIsActive
|
||||
? t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })
|
||||
: t("vault.deleteConfirm.portForwardingDesc")
|
||||
}
|
||||
confirmLabel={deleteTargetIsActive ? t("pf.deleteActive.confirm") : undefined}
|
||||
disabled={isDeleting}
|
||||
onOpenChange={(open) => {
|
||||
setShowDeleteConfirm(open);
|
||||
if (!open) setRuleToDelete(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t("pf.deleteActive.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setRuleToDelete(null);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteActiveRule}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t("pf.deleteActive.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmDeleteRule();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ProxyProfile } from "../types.ts";
|
||||
import type { Identity, ProxyProfile } from "../types.ts";
|
||||
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
@@ -18,6 +18,15 @@ const proxyProfile: ProxyProfile = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const proxyIdentity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
@@ -26,6 +35,7 @@ const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {
|
||||
React.createElement(ProxyPanel, {
|
||||
proxyConfig: undefined,
|
||||
proxyProfiles: [],
|
||||
identities: [],
|
||||
selectedProxyProfileId: undefined,
|
||||
onUpdateProxy: () => {},
|
||||
onSelectProxyProfile: () => {},
|
||||
@@ -102,6 +112,79 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel shows a clear missing state for stale proxy identities", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "http",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /Username/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has a stale identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing keychain identity/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an incomplete identity", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: undefined }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity needs a username and password/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel warns when a saved proxy profile has an unreadable identity password", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "office-proxy.example.com",
|
||||
port: 8080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("ProxyPanel supports custom ProxyCommand settings", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
@@ -127,3 +210,19 @@ test("ProxyPanel uses a dropdown for proxy type selection", () => {
|
||||
assert.match(markup, /role="combobox"/);
|
||||
assert.match(markup, /aria-label="Type"/);
|
||||
});
|
||||
|
||||
test("ProxyPanel offers keychain identities for HTTP and SOCKS5 proxy credentials", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: {
|
||||
type: "socks5",
|
||||
host: "manual-proxy.example.com",
|
||||
port: 1080,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
identities: [proxyIdentity],
|
||||
});
|
||||
|
||||
assert.match(markup, /Keychain identity/);
|
||||
assert.match(markup, /Proxy login/);
|
||||
assert.match(markup, /proxy-user/);
|
||||
});
|
||||
@@ -6,8 +6,8 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { isValidProxyPort } from "../domain/proxyProfiles.ts";
|
||||
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { ProxyProfile } from "../types.ts";
|
||||
import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
|
||||
import type { Identity, ProxyProfile } from "../types.ts";
|
||||
import { prepareProxyProfileForSave, ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
id: "proxy-1",
|
||||
@@ -20,6 +20,15 @@ const proxyProfile: ProxyProfile = {
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const proxyIdentity: Identity = {
|
||||
id: "identity-1",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "proxy-secret",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
const installStorageStub = (viewMode: string | null = null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (viewMode) {
|
||||
@@ -53,6 +62,7 @@ const renderManager = (
|
||||
proxyProfiles: profiles,
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
identities: [proxyIdentity],
|
||||
onUpdateProxyProfiles: () => {},
|
||||
onUpdateHosts: () => {},
|
||||
onUpdateGroupConfigs: () => {},
|
||||
@@ -108,3 +118,108 @@ test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", ()
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave saves identity auth without stale manual credentials", () => {
|
||||
const result = prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
label: " Office Proxy ",
|
||||
config: {
|
||||
type: "http",
|
||||
host: " proxy.example.com ",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
username: "stale-user",
|
||||
password: "stale-secret",
|
||||
},
|
||||
},
|
||||
[proxyIdentity],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.deepEqual(result.saved?.config, {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
});
|
||||
assert.equal(result.saved?.label, "Office Proxy");
|
||||
assert.equal(result.saved?.updatedAt, 2);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave rejects missing and incomplete proxy identities", () => {
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: "missing-identity",
|
||||
},
|
||||
},
|
||||
[proxyIdentity],
|
||||
).error,
|
||||
"missingIdentity",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: undefined }],
|
||||
).error,
|
||||
"incompleteIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", () => {
|
||||
assert.equal(
|
||||
prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 3128,
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
).error,
|
||||
"unreadableIdentity",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareProxyProfileForSave strips stale ProxyCommand data from HTTP/SOCKS profiles", () => {
|
||||
const result = prepareProxyProfileForSave(
|
||||
{
|
||||
...proxyProfile,
|
||||
config: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
command: "cloudflared access ssh --hostname %h --token secret",
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
assert.deepEqual(result.saved?.config, {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
username: "proxy-user",
|
||||
password: "proxy-secret",
|
||||
});
|
||||
});
|
||||
@@ -18,16 +18,21 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
normalizeManualProxyConfig,
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
|
||||
import type { GroupConfig, Host, Identity, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
@@ -74,11 +79,57 @@ interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
identities: Identity[];
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
}
|
||||
|
||||
export type ProxyProfileSaveError =
|
||||
| "required"
|
||||
| "port"
|
||||
| "missingIdentity"
|
||||
| "incompleteIdentity"
|
||||
| "unreadableIdentity";
|
||||
|
||||
export const prepareProxyProfileForSave = (
|
||||
draft: ProxyProfile,
|
||||
identities: Identity[],
|
||||
updatedAt = Date.now(),
|
||||
): { saved?: ProxyProfile; error?: ProxyProfileSaveError } => {
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
const command = draft.config.command?.trim() || "";
|
||||
const isCommand = isProxyCommandConfig(draft.config);
|
||||
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
|
||||
return { error: "required" };
|
||||
}
|
||||
if (!isCommand && !isValidProxyPort(draft.config.port)) {
|
||||
return { error: "port" };
|
||||
}
|
||||
if (!isCommand && hasMissingProxyIdentity(draft.config, identities)) {
|
||||
return { error: "missingIdentity" };
|
||||
}
|
||||
if (!isCommand && hasIncompleteProxyIdentity(draft.config, identities)) {
|
||||
return { error: "incompleteIdentity" };
|
||||
}
|
||||
if (!isCommand && hasUnreadableProxyCredential(draft.config, identities)) {
|
||||
return { error: "unreadableIdentity" };
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeManualProxyConfig(draft.config);
|
||||
if (!normalizedConfig) return { error: "required" };
|
||||
|
||||
return {
|
||||
saved: {
|
||||
...draft,
|
||||
label,
|
||||
config: normalizedConfig,
|
||||
updatedAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDraftProfile = (): ProxyProfile => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@@ -217,6 +268,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
proxyProfiles,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
identities,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateHosts,
|
||||
onUpdateGroupConfigs,
|
||||
@@ -232,6 +284,18 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
const [draft, setDraft] = useState<ProxyProfile | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const manualCredentialsValue = "__manual_credentials__";
|
||||
const missingIdentityValue = "__missing_identity__";
|
||||
const selectedDraftIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === draft?.config.identityId),
|
||||
[draft?.config.identityId, identities],
|
||||
);
|
||||
const hasMissingDraftIdentity = hasMissingProxyIdentity(draft?.config, identities);
|
||||
const hasIncompleteDraftIdentity = hasIncompleteProxyIdentity(draft?.config, identities);
|
||||
const hasUnreadableDraftIdentity = hasUnreadableProxyCredential(draft?.config, identities);
|
||||
const selectedDraftIdentityValue =
|
||||
selectedDraftIdentity?.id ||
|
||||
(hasMissingDraftIdentity ? missingIdentityValue : manualCredentialsValue);
|
||||
|
||||
const usageByProfileId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
@@ -263,15 +327,12 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
|
||||
const updateDraftConfig = (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[field]: value,
|
||||
},
|
||||
config: updateProxyConfigField(prev.config, field, value),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -304,38 +365,21 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
const command = draft.config.command?.trim() || "";
|
||||
const isCommand = isProxyCommandConfig(draft.config);
|
||||
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
|
||||
toast.error(t("proxyProfiles.error.required"));
|
||||
const result = prepareProxyProfileForSave(draft, identities);
|
||||
if (!result.saved) {
|
||||
const messageKey = result.error === "port"
|
||||
? "proxyProfiles.error.port"
|
||||
: result.error === "missingIdentity"
|
||||
? "hostDetails.proxyPanel.missingIdentity"
|
||||
: result.error === "incompleteIdentity"
|
||||
? "hostDetails.proxyPanel.incompleteIdentity"
|
||||
: result.error === "unreadableIdentity"
|
||||
? "hostDetails.proxyPanel.unreadableIdentity"
|
||||
: "proxyProfiles.error.required";
|
||||
toast.error(t(messageKey));
|
||||
return;
|
||||
}
|
||||
if (!isCommand && !isValidProxyPort(draft.config.port)) {
|
||||
toast.error(t("proxyProfiles.error.port"));
|
||||
return;
|
||||
}
|
||||
|
||||
const saved: ProxyProfile = {
|
||||
...draft,
|
||||
label,
|
||||
config: isCommand
|
||||
? {
|
||||
type: "command",
|
||||
host: "",
|
||||
port: 0,
|
||||
command,
|
||||
}
|
||||
: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const saved = result.saved;
|
||||
|
||||
onUpdateProxyProfiles(
|
||||
proxyProfiles.some((profile) => profile.id === saved.id)
|
||||
@@ -565,21 +609,90 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
value={draft.config.username || ""}
|
||||
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
type="password"
|
||||
value={draft.config.password || ""}
|
||||
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
{identities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
</p>
|
||||
<Select
|
||||
value={selectedDraftIdentityValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === missingIdentityValue) return;
|
||||
updateDraftConfig(
|
||||
"identityId",
|
||||
value === manualCredentialsValue ? undefined : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={manualCredentialsValue}>
|
||||
{t("hostDetails.proxyPanel.manualCredentials")}
|
||||
</SelectItem>
|
||||
{hasMissingDraftIdentity && (
|
||||
<SelectItem value={missingIdentityValue}>
|
||||
{t("hostDetails.proxyPanel.missingIdentity")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{identities.map((identity) => (
|
||||
<SelectItem key={identity.id} value={identity.id}>
|
||||
{identity.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{hasMissingDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.missingIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{hasIncompleteDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.incompleteIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{hasUnreadableDraftIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t("hostDetails.proxyPanel.unreadableIdentity")}
|
||||
</div>
|
||||
)}
|
||||
{selectedDraftIdentity ? (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxyPanel.keychainIdentity")}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedDraftIdentity.label} - {selectedDraftIdentity.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
value={draft.config.username || ""}
|
||||
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
type="password"
|
||||
value={draft.config.password || ""}
|
||||
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
@@ -53,6 +54,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [noAutoRun, setNoAutoRun] = useState(false);
|
||||
const [multiLineRunMode, setMultiLineRunMode] = useState<Snippet["multiLineRunMode"]>(undefined);
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -66,6 +68,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setCommand(getQuickAddSnippetInitialCommand(event));
|
||||
setPackagePath('');
|
||||
setNoAutoRun(false);
|
||||
setMultiLineRunMode(undefined);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
@@ -84,6 +87,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setNoAutoRun(snippet.noAutoRun ?? false);
|
||||
setMultiLineRunMode(snippet.multiLineRunMode);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -131,6 +135,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
multiLineRunMode,
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
@@ -141,10 +146,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
multiLineRunMode,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun, multiLineRunMode]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -220,6 +226,22 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{t('snippets.field.multiLineRunMode')}</Label>
|
||||
<Select
|
||||
value={multiLineRunMode ?? 'paste'}
|
||||
onValueChange={(value) => setMultiLineRunMode(value === 'lineDelay' ? 'lineDelay' : undefined)}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="paste">{t('snippets.field.multiLineRunMode.paste')}</SelectItem>
|
||||
<SelectItem value="lineDelay">{t('snippets.field.multiLineRunMode.lineDelay')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
|
||||
@@ -120,6 +120,10 @@ type TerminalTabSettingsProps = Pick<
|
||||
| 'setTerminalFontSize'
|
||||
| 'terminalSettings'
|
||||
| 'updateTerminalSetting'
|
||||
| 'terminalSidePanelAutoOpen'
|
||||
| 'setTerminalSidePanelAutoOpen'
|
||||
| 'terminalSidePanelAutoOpenTab'
|
||||
| 'setTerminalSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
>;
|
||||
@@ -142,6 +146,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
terminalSidePanelAutoOpen,
|
||||
setTerminalSidePanelAutoOpen,
|
||||
terminalSidePanelAutoOpenTab,
|
||||
setTerminalSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
}) {
|
||||
@@ -166,6 +174,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setTerminalFontSize={setTerminalFontSize}
|
||||
terminalSettings={terminalSettings}
|
||||
updateTerminalSetting={updateTerminalSetting}
|
||||
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
|
||||
setTerminalSidePanelAutoOpen={setTerminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
|
||||
setTerminalSidePanelAutoOpenTab={setTerminalSidePanelAutoOpenTab}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
@@ -475,6 +487,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
|
||||
setTerminalSidePanelAutoOpen={settings.setTerminalSidePanelAutoOpen}
|
||||
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
|
||||
setTerminalSidePanelAutoOpenTab={settings.setTerminalSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
@@ -532,6 +548,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
sshDeepLinkEnabled={settings.sshDeepLinkEnabled}
|
||||
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
|
||||
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
|
||||
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
|
||||
restorePreviousSession={settings.restorePreviousSession}
|
||||
setRestorePreviousSession={settings.setRestorePreviousSession}
|
||||
restoreTerminalCwd={settings.restoreTerminalCwd}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
import {
|
||||
mergeLatestFollowTerminalCwdHostSetting,
|
||||
resolveHostFollowTerminalCwd,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
@@ -778,25 +779,32 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
||||
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const followSyncGenerationRef = useRef(0);
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
connectionIdRef.current = connectionId;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
followSyncGenerationRef.current += 1;
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
blockedFollowRef.current = null;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
followTerminalCwdHost?.id,
|
||||
connectionId,
|
||||
invalidateInFlightFollowSync,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -805,15 +813,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockedFollow = blockedFollowRef.current;
|
||||
if (
|
||||
shouldClearBlockedFollowOnReach(
|
||||
blockedFollowRef.current,
|
||||
blockedFollow,
|
||||
connectionId,
|
||||
connectionPath,
|
||||
sftp.leftPane.loading,
|
||||
)
|
||||
) {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = blockedFollow;
|
||||
}
|
||||
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
||||
|
||||
@@ -824,6 +834,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
|
||||
if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
@@ -834,16 +848,18 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
|
||||
let terminalCwd = activeTerminalCwd;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (
|
||||
syncGeneration !== followSyncGenerationRef.current
|
||||
|| !effectiveFollowTerminalCwdRef.current
|
||||
|| !canFollowTerminalCwdRef.current
|
||||
) {
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -857,16 +873,43 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
blockedFollow: blockedFollowRef.current,
|
||||
handledFollow: handledFollowRef.current,
|
||||
})) {
|
||||
if (
|
||||
connection?.id
|
||||
&& !connection.isLocal
|
||||
&& connection.status === "connected"
|
||||
&& connection.currentPath === terminalCwd
|
||||
) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd);
|
||||
if (
|
||||
syncGeneration !== followSyncGenerationRef.current
|
||||
|| !effectiveFollowTerminalCwdRef.current
|
||||
|| !canFollowTerminalCwdRef.current
|
||||
) {
|
||||
const expectedConnectionId = connection?.id ?? null;
|
||||
const shouldApplyCurrentFollowSync = () => (
|
||||
shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedConnectionId,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
expectedTerminalCwd: terminalCwd,
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
})
|
||||
);
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
});
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -877,8 +920,11 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
if (navigateResult === "failed" && currentConnection.id) {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "superseded" && currentConnection.id) {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
}
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
@@ -892,9 +938,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
const nextEnabled = !effectiveFollowTerminalCwd;
|
||||
if (!nextEnabled) {
|
||||
invalidateInFlightFollowSync();
|
||||
}
|
||||
invalidateInFlightFollowSync();
|
||||
if (followTerminalCwdHost?.id) {
|
||||
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
||||
}
|
||||
@@ -910,7 +954,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
sftp.leftPane.connection?.currentPath,
|
||||
connectionId,
|
||||
sftp.leftPane.connection?.status,
|
||||
sftp.leftPane.connection?.isLocal,
|
||||
syncFollowToTerminalCwd,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
vaultPrimaryIconClass,
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
clearVaultDropIndicator as clearSnippetDropIndicator,
|
||||
getVaultDropIntent as getPackageDropIntent,
|
||||
@@ -498,6 +499,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: 'snippet' | 'package';
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
|
||||
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
|
||||
@@ -758,6 +764,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
noAutoRun: editingSnippet.noAutoRun,
|
||||
multiLineRunMode: editingSnippet.multiLineRunMode,
|
||||
order: editingSnippet.order,
|
||||
kind: editingSnippet.kind,
|
||||
language: editingSnippet.language,
|
||||
@@ -1188,7 +1195,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
const performDeletePackage = (path: string) => {
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
@@ -1208,6 +1215,44 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeletePackage = (path: string) => {
|
||||
setDeleteTarget({
|
||||
type: 'package',
|
||||
id: path,
|
||||
name: path,
|
||||
});
|
||||
};
|
||||
|
||||
const requestDeleteSnippet = (id: string) => {
|
||||
const snippet = snippets.find((item) => item.id === id);
|
||||
setDeleteTarget({
|
||||
type: 'snippet',
|
||||
id,
|
||||
name: snippet?.label || t('snippets.panel.editTitle'),
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeleteTarget = () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.type === 'package') {
|
||||
performDeletePackage(deleteTarget.id);
|
||||
} else {
|
||||
onDelete(deleteTarget.id);
|
||||
setSelectedSnippetIds((prev) => {
|
||||
if (!prev.has(deleteTarget.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(deleteTarget.id);
|
||||
return next;
|
||||
});
|
||||
if (editingSnippet.id === deleteTarget.id) {
|
||||
handleClosePanel();
|
||||
}
|
||||
}
|
||||
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const isAbsolute = source.startsWith('/');
|
||||
@@ -1580,7 +1625,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
t={t}
|
||||
handleClosePanel={handleClosePanel}
|
||||
editingSnippet={editingSnippet}
|
||||
onDelete={onDelete}
|
||||
onDelete={requestDeleteSnippet}
|
||||
handleSave={handleSave}
|
||||
handleSaveAndRun={handleSaveAndRun}
|
||||
setEditingSnippet={setEditingSnippet}
|
||||
@@ -1868,7 +1913,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.package')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
@@ -2006,7 +2051,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
|
||||
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onDelete(snippet.id)}>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
@@ -2070,6 +2115,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t('vault.deleteConfirm.title', {
|
||||
name: deleteTarget?.name ?? '',
|
||||
})}
|
||||
description={
|
||||
deleteTarget?.type === 'package'
|
||||
? t('vault.deleteConfirm.packageDesc')
|
||||
: t('vault.deleteConfirm.desc')
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteTarget}
|
||||
/>
|
||||
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ScriptEditorModal } from './scripts/ScriptEditorModal';
|
||||
import { isScriptSnippet } from '../domain/snippetScript.ts';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
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';
|
||||
@@ -155,7 +156,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
@@ -286,6 +286,28 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.multiLineRunMode')}</p>
|
||||
<Select
|
||||
value={editingSnippet.multiLineRunMode ?? 'paste'}
|
||||
onValueChange={(value) => setEditingSnippet({
|
||||
...editingSnippet,
|
||||
multiLineRunMode: value === 'lineDelay' ? 'lineDelay' : undefined,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="paste">{t('snippets.field.multiLineRunMode.paste')}</SelectItem>
|
||||
<SelectItem value="lineDelay">{t('snippets.field.multiLineRunMode.lineDelay')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t('snippets.field.multiLineRunModeHint')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+215
-42
@@ -45,6 +45,12 @@ import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import {
|
||||
TERMINAL_AUTO_RECONNECT_DELAY_MS,
|
||||
canAttemptTerminalAutoReconnect,
|
||||
shouldAutoReconnectAfterExit,
|
||||
shouldContinueAutoReconnectAfterFailure,
|
||||
} from "../application/state/terminalAutoReconnect";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
|
||||
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
|
||||
@@ -98,7 +104,11 @@ import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrite } from "./terminal/programmaticCommandLog";
|
||||
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import {
|
||||
createTerminalSessionStarters,
|
||||
type PendingAuth,
|
||||
type TerminalSessionDataMeta,
|
||||
} from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
@@ -122,6 +132,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
||||
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
||||
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
|
||||
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
|
||||
@@ -207,6 +218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
restoreTerminalCwd = false,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
multiLineRunMode,
|
||||
pendingScriptId,
|
||||
pendingScript,
|
||||
reuseConnectionFromSessionId,
|
||||
@@ -358,6 +370,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// chained xterm.write callbacks verify the token before proceeding so a
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoReconnectLoopActiveRef = useRef(false);
|
||||
const autoReconnectAttemptRef = useRef(0);
|
||||
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
|
||||
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
@@ -555,6 +572,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
|
||||
getInitialTerminalStatus()
|
||||
));
|
||||
const hasEverConnectedRef = useRef(status === "connected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const lastToastedErrorRef = useRef<string | null>(null);
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
@@ -910,7 +928,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}).then((result) => {
|
||||
handleOsc7SetupOpenChange(false);
|
||||
if (result.success) {
|
||||
toast.success(t("terminal.osc7Setup.configured"));
|
||||
toast.success(result.sentToTerminal
|
||||
? t("terminal.osc7Setup.sent")
|
||||
: t("terminal.osc7Setup.configured"));
|
||||
return;
|
||||
}
|
||||
toast.error(result.error || t("terminal.osc7Setup.failed"));
|
||||
@@ -994,14 +1014,74 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const resolvedChainHosts =
|
||||
chainHosts;
|
||||
|
||||
const updateStatus = (next: TerminalSession["status"]) => {
|
||||
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
|
||||
if (autoReconnectTimerRef.current) {
|
||||
clearTimeout(autoReconnectTimerRef.current);
|
||||
autoReconnectTimerRef.current = null;
|
||||
}
|
||||
if (options?.stopLoop !== false) {
|
||||
autoReconnectLoopActiveRef.current = false;
|
||||
autoReconnectAttemptRef.current = 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateStatus = useCallback((next: TerminalSession["status"]) => {
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
if (next === "connected") {
|
||||
hasEverConnectedRef.current = true;
|
||||
clearAutoReconnect();
|
||||
}
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
}, [clearAutoReconnect, onStatusChange, sessionId]);
|
||||
const updateStatusRef = useRef(updateStatus);
|
||||
updateStatusRef.current = updateStatus;
|
||||
|
||||
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
|
||||
const shouldSchedule = trigger?.evt
|
||||
? shouldAutoReconnectAfterExit({
|
||||
evt: trigger.evt,
|
||||
host,
|
||||
terminalSettings,
|
||||
hasEverConnected: hasEverConnectedRef.current,
|
||||
})
|
||||
: shouldContinueAutoReconnectAfterFailure({
|
||||
host,
|
||||
terminalSettings,
|
||||
loopActive: autoReconnectLoopActiveRef.current,
|
||||
});
|
||||
|
||||
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
|
||||
hasTerminalRuntime: Boolean(termRef.current),
|
||||
isHibernated: hibernatedRef.current,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
autoReconnectLoopActiveRef.current = true;
|
||||
if (autoReconnectTimerRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
autoReconnectAttemptRef.current += 1;
|
||||
const attempt = autoReconnectAttemptRef.current;
|
||||
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
|
||||
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
|
||||
|
||||
setError(null);
|
||||
setShowLogs(true);
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setProgressLogs((prev) => [...prev, scheduledMessage]);
|
||||
updateStatus("connecting");
|
||||
|
||||
autoReconnectTimerRef.current = setTimeout(() => {
|
||||
autoReconnectTimerRef.current = null;
|
||||
startReconnectRef.current?.("auto");
|
||||
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
|
||||
|
||||
return true;
|
||||
}, [host, t, terminalSettings, updateStatus]);
|
||||
|
||||
const prepareRestoredReconnect = useCallback(() => {
|
||||
if (restoreState !== "restored-disconnected") {
|
||||
suppressHostStartupCommandRef.current = false;
|
||||
@@ -1087,6 +1167,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hasRuntimeRef.current = false;
|
||||
};
|
||||
|
||||
const clearHibernateRuntimeState = useCallback(() => {
|
||||
hibernatedRef.current = false;
|
||||
softHiddenRef.current = false;
|
||||
hibernateSnapshotRef.current = "";
|
||||
hibernateViewportSnapshotRef.current = "";
|
||||
hibernateScrollbackSnapshotRef.current = "";
|
||||
hibernateContextSnapshotRef.current = "";
|
||||
hibernateContextViewportSnapshotRef.current = "";
|
||||
hibernateContextScrollbackSnapshotRef.current = "";
|
||||
hibernatePendingBufferRef.current = "";
|
||||
hibernateAlternateScreenRef.current = false;
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const forceCloseHibernatedSession = useCallback(() => {
|
||||
if (!terminalDataCapturedRef.current) {
|
||||
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
|
||||
@@ -1112,18 +1206,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
sessionRef.current = null;
|
||||
hibernatedRef.current = false;
|
||||
softHiddenRef.current = false;
|
||||
hibernateSnapshotRef.current = "";
|
||||
hibernateViewportSnapshotRef.current = "";
|
||||
hibernateScrollbackSnapshotRef.current = "";
|
||||
hibernateContextSnapshotRef.current = "";
|
||||
hibernateContextViewportSnapshotRef.current = "";
|
||||
hibernateContextScrollbackSnapshotRef.current = "";
|
||||
hibernatePendingBufferRef.current = "";
|
||||
hibernateAlternateScreenRef.current = false;
|
||||
terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
clearHibernateRuntimeState();
|
||||
}, [clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
|
||||
|
||||
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -1157,8 +1241,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
exitMessage,
|
||||
);
|
||||
onSessionExit?.(sessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
});
|
||||
}, [onSessionExit, sessionId, terminalBackend]);
|
||||
}, [onSessionExit, scheduleAutoReconnect, sessionId, terminalBackend]);
|
||||
|
||||
const applyHibernateSnapshot = useCallback((
|
||||
snapshot: {
|
||||
@@ -1277,6 +1362,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = false;
|
||||
clearAutoReconnect();
|
||||
cleanupSession();
|
||||
disposeRuntimeOnly();
|
||||
};
|
||||
@@ -1291,6 +1377,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
reuseConnectionFromSessionId,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
multiLineRunMode,
|
||||
shellType,
|
||||
suppressHostStartupCommandRef,
|
||||
terminalSettings,
|
||||
@@ -1365,21 +1452,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSessionExit: (closedSessionId, evt) => {
|
||||
clearTerminalCwd();
|
||||
onSessionExit?.(closedSessionId, evt);
|
||||
scheduleAutoReconnect({ evt });
|
||||
},
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalOutput: onTerminalOutput
|
||||
? (chunk: string) => {
|
||||
? (chunk: string, meta?: TerminalSessionDataMeta) => {
|
||||
if (/password|passphrase|口令/i.test(chunk)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
}
|
||||
appendOutputTriggerOutputRef.current(chunk);
|
||||
appendOutputTriggerOutputRef.current(chunk, meta);
|
||||
onTerminalOutput(sessionId, chunk);
|
||||
}
|
||||
: (chunk: string) => {
|
||||
: (chunk: string, meta?: TerminalSessionDataMeta) => {
|
||||
if (/password|passphrase|口令/i.test(chunk)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
}
|
||||
appendOutputTriggerOutputRef.current(chunk);
|
||||
appendOutputTriggerOutputRef.current(chunk, meta);
|
||||
},
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
|
||||
@@ -1394,12 +1482,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'disconnected') {
|
||||
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
|
||||
connectScriptsConsumedRef.current = false;
|
||||
connectScriptsCompletedIdsRef.current = new Set();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "disconnected") return;
|
||||
scheduleAutoReconnect();
|
||||
}, [scheduleAutoReconnect, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoReconnectLoopActiveRef.current) return;
|
||||
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
|
||||
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
|
||||
clearAutoReconnect();
|
||||
if (hadPendingReconnect) {
|
||||
updateStatus("disconnected");
|
||||
}
|
||||
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingScriptRunIdRef.current = null;
|
||||
pendingScriptHandledRef.current = null;
|
||||
@@ -1617,10 +1720,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
|
||||
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
|
||||
const safeFit = (options?: { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }) => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!fitAddon) return;
|
||||
if (options?.requireVisible && !isVisibleRef.current) return;
|
||||
if (!isVisibleRef.current && !options?.allowHidden) {
|
||||
lastFittedSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
@@ -1690,7 +1796,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (
|
||||
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
|
||||
typeof requestAnimationFrame === "function"
|
||||
typeof requestAnimationFrame === "function" &&
|
||||
!options?.immediate
|
||||
) {
|
||||
requestAnimationFrame(runFit);
|
||||
} else {
|
||||
@@ -1745,14 +1852,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const executeSnippetCommand = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
options?: { broadcast?: boolean },
|
||||
options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"] },
|
||||
) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun })
|
||||
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, {
|
||||
noAutoRun,
|
||||
multiLineRunMode: options?.multiLineRunMode,
|
||||
})
|
||||
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
|
||||
: undefined;
|
||||
const isMultiLine = data.includes('\n');
|
||||
@@ -1799,7 +1909,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
executeSnippetCommand(command, snippet.noAutoRun);
|
||||
executeSnippetCommand(command, snippet.noAutoRun, {
|
||||
multiLineRunMode: snippet.multiLineRunMode,
|
||||
});
|
||||
}, [executeSnippetCommand, sessionId, t]);
|
||||
|
||||
const onSnippetShortkeyRef = useRef(executeSnippet);
|
||||
@@ -1878,12 +1990,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultPath = getRememberedYmodemSendDefaultPath();
|
||||
const filePath = await selectFile(
|
||||
t("terminal.ymodem.selectFile"),
|
||||
undefined,
|
||||
defaultPath,
|
||||
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
|
||||
);
|
||||
if (!filePath) return;
|
||||
rememberYmodemSendFilePath(filePath);
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
||||
toast.info(t("terminal.ymodem.started", { fileName }));
|
||||
@@ -1945,6 +2059,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (pendingHostKeyRequestId) {
|
||||
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
|
||||
}
|
||||
clearAutoReconnect();
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
setIsCancelling(true);
|
||||
@@ -1964,9 +2079,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const handleDismissDisconnectedDialog = () => {
|
||||
setIsDisconnectedDialogDismissed(true);
|
||||
queueMicrotask(() => termRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
clearAutoReconnect();
|
||||
retryTokenRef.current = null;
|
||||
restoreCwdIntentRef.current = null;
|
||||
onCloseSession?.(sessionId);
|
||||
@@ -2008,9 +2125,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setPendingHostKeyRequestId(null);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
const startReconnect = (mode: "manual" | "auto" = "manual") => {
|
||||
if (!termRef.current && mode === "auto" && hibernatedRef.current) {
|
||||
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
|
||||
if (!wakeForReconnect) {
|
||||
updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
void wakeForReconnect().then((woke) => {
|
||||
if (woke) {
|
||||
startReconnectRef.current?.("auto");
|
||||
return;
|
||||
}
|
||||
updateStatus("disconnected");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!termRef.current) return;
|
||||
prepareRestoredReconnect();
|
||||
if (mode === "manual") {
|
||||
clearAutoReconnect();
|
||||
prepareRestoredReconnect();
|
||||
} else {
|
||||
restoreCwdIntentRef.current = null;
|
||||
suppressHostStartupCommandRef.current = true;
|
||||
}
|
||||
cleanupSession();
|
||||
const term = termRef.current;
|
||||
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
||||
@@ -2024,12 +2162,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBootActiveRef.current = true;
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
if (mode === "manual") {
|
||||
hasRunStartupCommandRef.current = false;
|
||||
}
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setConnectionReuseFellBack(false);
|
||||
updateStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setProgressLogs((prev) => (
|
||||
mode === "auto"
|
||||
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
|
||||
: ["Retrying secure channel..."]
|
||||
));
|
||||
setShowLogs(true);
|
||||
|
||||
const startNewSession = () => {
|
||||
@@ -2079,6 +2223,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
);
|
||||
});
|
||||
};
|
||||
startReconnectRef.current = startReconnect;
|
||||
|
||||
const handleRetry = () => {
|
||||
startReconnect("manual");
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
|
||||
status,
|
||||
@@ -2285,12 +2434,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
recorder,
|
||||
]);
|
||||
|
||||
const statusDotTone =
|
||||
status === "connected"
|
||||
? "bg-emerald-400"
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const terminalPreviewVars = useMemo(() => {
|
||||
const { background, foreground, cursor } = effectiveTheme.colors;
|
||||
return {
|
||||
@@ -2448,6 +2591,36 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
});
|
||||
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
|
||||
|
||||
wakeHibernatedRuntimeForReconnectRef.current = async () => {
|
||||
if (!hibernatedRef.current) {
|
||||
return Boolean(termRef.current);
|
||||
}
|
||||
|
||||
const getPayload = (): TerminalHibernateWakePayload => ({
|
||||
snapshot: hibernateSnapshotRef.current,
|
||||
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
|
||||
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
|
||||
pendingBuffer: hibernatePendingBufferRef.current,
|
||||
alternateScreen: hibernateAlternateScreenRef.current,
|
||||
});
|
||||
|
||||
logger.info("[Terminal] Waking hibernated runtime for auto reconnect", {
|
||||
sessionId,
|
||||
snapshotChars: hibernateSnapshotRef.current.length,
|
||||
viewportChars: hibernateViewportSnapshotRef.current.length,
|
||||
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
|
||||
pendingChars: hibernatePendingBufferRef.current.length,
|
||||
});
|
||||
|
||||
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected: false }));
|
||||
if (accepted === false || !termRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearHibernateRuntimeState();
|
||||
return true;
|
||||
};
|
||||
|
||||
useTerminalHibernateEffect({
|
||||
sessionId,
|
||||
isVisible,
|
||||
@@ -2478,11 +2651,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
@@ -2490,7 +2663,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
|
||||
onDismiss={dismissScriptOverlay}
|
||||
/>
|
||||
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
|
||||
<ScriptSaveRecordingDialog
|
||||
open={saveRecordingOpen}
|
||||
code={recordedCode}
|
||||
|
||||
@@ -31,6 +31,8 @@ const baseProps = {
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
terminalSidePanelAutoOpen: false,
|
||||
terminalSidePanelAutoOpenTab: "scripts",
|
||||
sftpFollowTerminalCwd: false,
|
||||
setSftpFollowTerminalCwd: () => {},
|
||||
editorWordWrap: false,
|
||||
@@ -119,6 +121,24 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, terminalSidePanelAutoOpen: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, terminalSidePanelAutoOpenTab: "history" } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
|
||||
import {
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
@@ -81,6 +82,10 @@ import { resolveAiNoteArtifactPanelIntent } from './terminalLayer/aiNoteArtifact
|
||||
import {
|
||||
canUseDirectSessionWriteFallback,
|
||||
} from './terminalLayer/terminalLayerSessionRouting';
|
||||
import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveTerminalSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
|
||||
@@ -211,6 +216,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
terminalSidePanelAutoOpen = false,
|
||||
terminalSidePanelAutoOpenTab = DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
editorWordWrap,
|
||||
@@ -391,57 +398,78 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
|
||||
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
|
||||
const terminalSidePanelAutoOpenRef = useRef(terminalSidePanelAutoOpen);
|
||||
terminalSidePanelAutoOpenRef.current = terminalSidePanelAutoOpen;
|
||||
const terminalSidePanelAutoOpenTabRef = useRef(terminalSidePanelAutoOpenTab);
|
||||
terminalSidePanelAutoOpenTabRef.current = terminalSidePanelAutoOpenTab;
|
||||
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
|
||||
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
|
||||
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
|
||||
const proto = session.protocol;
|
||||
if (proto !== 'ssh' && proto !== 'mosh') return;
|
||||
if (status !== 'connected') return;
|
||||
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const proto = session.protocol;
|
||||
const sftpAvailable = proto === 'ssh' || proto === 'mosh';
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
|
||||
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
|
||||
enabled: terminalSidePanelAutoOpenRef.current,
|
||||
selectedTab: terminalSidePanelAutoOpenTabRef.current,
|
||||
sftpAvailable,
|
||||
});
|
||||
const targetPanel = autoOpenTarget ?? (sftpAutoOpenSidebarRef.current && sftpAvailable ? 'sftp' : null);
|
||||
if (!targetPanel) return;
|
||||
|
||||
lastSidePanelTabRef.current.set(tabId, targetPanel);
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
|
||||
// Determine the tab ID (workspace or solo session)
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
|
||||
// Only open if the sidebar is not already open for this tab
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
}
|
||||
: {
|
||||
// Quick Connect / temporary session — build minimal host from session data
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
} as Host;
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
} as Host;
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'sftp');
|
||||
return next;
|
||||
});
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
} else if (targetPanel === 'ai') {
|
||||
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'scripts') {
|
||||
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'theme') {
|
||||
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'system') {
|
||||
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
} else if (targetPanel === 'notes') {
|
||||
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
|
||||
}
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, targetPanel);
|
||||
return next;
|
||||
});
|
||||
}, [onUpdateSessionStatus]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
@@ -877,24 +905,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
// Workspace panes keep per-session font size so zooming one split does not
|
||||
// change global defaults or sibling panes (even when they share a host).
|
||||
if (session?.workspaceId) {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!sessionHost) return;
|
||||
|
||||
const rawHost = hostMapRef.current.get(sessionHost.id);
|
||||
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
|
||||
if (usesGlobalFontSize) {
|
||||
const rawHost = sessionHost ? hostMapRef.current.get(sessionHost.id) : null;
|
||||
const target = resolveTerminalFontSizeUpdateTarget({ session, sessionHost, rawHost });
|
||||
if (target.kind === 'none') return;
|
||||
if (target.kind === 'session') {
|
||||
onUpdateSessionFontSize?.(sessionId, nextFontSize);
|
||||
} else if (target.kind === 'global') {
|
||||
onUpdateTerminalFontSize?.(nextFontSize);
|
||||
return;
|
||||
} else {
|
||||
onUpdateHost({ ...target.host, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}
|
||||
|
||||
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
|
||||
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
|
||||
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
@@ -1315,13 +1336,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSnippetClickForFocusedSession = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
|
||||
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
|
||||
) => {
|
||||
const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id;
|
||||
if (!sessionId) return;
|
||||
const executor = snippetExecutorsRef.current.get(sessionId);
|
||||
if (executor) {
|
||||
executor(command, noAutoRun);
|
||||
executor(command, noAutoRun, options);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1330,7 +1351,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun })
|
||||
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, {
|
||||
noAutoRun,
|
||||
multiLineRunMode: options?.multiLineRunMode,
|
||||
})
|
||||
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
|
||||
: undefined;
|
||||
terminalBackend.writeToSession(sessionId, data, {
|
||||
@@ -1371,7 +1395,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
|
||||
handleSnippetClickForFocusedSession(command, snippet.noAutoRun, {
|
||||
multiLineRunMode: snippet.multiLineRunMode,
|
||||
});
|
||||
}, [getActiveTerminalSessionId, handleSnippetClickForFocusedSession, hosts, t]);
|
||||
|
||||
const handleRunScriptFromPanel = useCallback(async (snippet: Snippet) => {
|
||||
|
||||
+109
-4
@@ -30,10 +30,17 @@ const {
|
||||
hasWorkspaceSessionDrag,
|
||||
isPointInsideRect,
|
||||
} = await import("../application/state/terminalDragData.ts");
|
||||
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
||||
const {
|
||||
activateLogViewTab,
|
||||
createSessionTopTabDoubleClickHandler,
|
||||
formatSessionTopTabLabel,
|
||||
formatSessionTopTabTooltip,
|
||||
stopCloseButtonDoubleClickPropagation,
|
||||
} = await import("./top-tabs/TopTabItems.tsx");
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
@@ -104,14 +111,112 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
|
||||
});
|
||||
|
||||
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
||||
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /resolveHostIconAppearance\(host\)/);
|
||||
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
|
||||
assert.ok(
|
||||
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
|
||||
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
|
||||
"custom host icon should be checked before distro fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tabs copy the session on double click through the existing copy handler", () => {
|
||||
const copiedSessionIds: string[] = [];
|
||||
const handleDoubleClick = createSessionTopTabDoubleClickHandler(
|
||||
(sessionId) => copiedSessionIds.push(sessionId),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
|
||||
|
||||
assert.deepEqual(copiedSessionIds, ["session-1"]);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
|
||||
});
|
||||
|
||||
test("session close button double click stays on the close button", () => {
|
||||
let stopPropagationCount = 0;
|
||||
|
||||
stopCloseButtonDoubleClickPropagation({
|
||||
stopPropagation: () => {
|
||||
stopPropagationCount += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(stopPropagationCount, 1);
|
||||
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
||||
});
|
||||
|
||||
test("session top tab label only shows the host label for ssh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
hostLabel: "prod-web",
|
||||
hostname: "10.1.2.34",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tab label treats missing protocol as ssh", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
hostLabel: "prod-web",
|
||||
hostname: "10.1.2.34",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionTopTabTooltip({
|
||||
username: "root",
|
||||
hostname: "10.1.2.34",
|
||||
port: 22,
|
||||
}),
|
||||
"root@10.1.2.34:22",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tab label avoids duplicating the target address", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
hostLabel: "10.1.2.34",
|
||||
hostname: "10.1.2.34",
|
||||
protocol: "ssh",
|
||||
}),
|
||||
"10.1.2.34",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tab tooltip includes username and port when present", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabTooltip({
|
||||
username: "root",
|
||||
hostname: "db.internal",
|
||||
port: 2222,
|
||||
protocol: "ssh",
|
||||
}),
|
||||
"root@db.internal:2222",
|
||||
);
|
||||
});
|
||||
|
||||
test("session top tab helpers ignore local and mosh sessions", () => {
|
||||
assert.equal(
|
||||
formatSessionTopTabLabel({
|
||||
hostLabel: "Local shell",
|
||||
hostname: "127.0.0.1",
|
||||
protocol: "local",
|
||||
}),
|
||||
"Local shell",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionTopTabTooltip({
|
||||
username: "root",
|
||||
hostname: "10.1.2.34",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
||||
const data = new Map([
|
||||
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
|
||||
|
||||
test("AsideActionMenuItem closes its menu before running the action", () => {
|
||||
const events: string[] = [];
|
||||
|
||||
invokeAsideActionMenuItemClick(
|
||||
() => events.push("close"),
|
||||
() => events.push("action"),
|
||||
);
|
||||
|
||||
assert.deepEqual(events, ["close", "action"]);
|
||||
});
|
||||
|
||||
test("AsideActionMenuItem still closes when no action is provided", () => {
|
||||
const events: string[] = [];
|
||||
|
||||
invokeAsideActionMenuItemClick(() => events.push("close"));
|
||||
|
||||
assert.deepEqual(events, ["close"]);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyComboboxWheelScroll,
|
||||
comboboxWheelDeltaToPixels,
|
||||
type ComboboxScrollableTarget,
|
||||
} from "./ui/combobox.tsx";
|
||||
|
||||
const source = readFileSync(new URL("./ui/combobox.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("combobox wheel deltas normalize to pixels", () => {
|
||||
assert.equal(comboboxWheelDeltaToPixels(12, 0), 12);
|
||||
assert.equal(comboboxWheelDeltaToPixels(3, 1), 48);
|
||||
assert.equal(comboboxWheelDeltaToPixels(1, 2), 280);
|
||||
});
|
||||
|
||||
test("combobox wheel input scrolls the overflowing option list", () => {
|
||||
const target: ComboboxScrollableTarget = {
|
||||
clientHeight: 100,
|
||||
scrollHeight: 300,
|
||||
scrollTop: 20,
|
||||
};
|
||||
|
||||
assert.equal(applyComboboxWheelScroll(target, 5, 1), true);
|
||||
assert.equal(target.scrollTop, 100);
|
||||
});
|
||||
|
||||
test("combobox wheel input is ignored when the option list does not overflow", () => {
|
||||
const target: ComboboxScrollableTarget = {
|
||||
clientHeight: 300,
|
||||
scrollHeight: 300,
|
||||
scrollTop: 20,
|
||||
};
|
||||
|
||||
assert.equal(applyComboboxWheelScroll(target, 5, 1), false);
|
||||
assert.equal(target.scrollTop, 20);
|
||||
});
|
||||
|
||||
test("combobox option popovers capture wheel events inside the popup list", () => {
|
||||
assert.match(source, /onWheelCapture=\{handleWheelCapture\}/);
|
||||
assert.match(source, /event\.preventDefault\(\)[\s\S]*event\.stopPropagation\(\)[\s\S]*event\.nativeEvent\.stopImmediatePropagation\(\)/);
|
||||
assert.match(source, /app-no-drag p-0 border-border\/60/);
|
||||
assert.doesNotMatch(source, /from "\.\/scroll-area"/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root, path), "utf8");
|
||||
}
|
||||
|
||||
function functionBody(source: string, functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
|
||||
const nextConst = source.indexOf("\n const ", start + 1);
|
||||
const nextHook = source.indexOf("\n use", start + 1);
|
||||
const candidates = [nextConst, nextHook].filter((index) => index > start);
|
||||
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test("host and AI provider deletion use in-app confirmation dialogs", () => {
|
||||
const appSource = readProjectFile("App.tsx");
|
||||
const aiSettingsSource = readProjectFile("components/settings/tabs/SettingsAITab.tsx");
|
||||
|
||||
assert.match(appSource, /import \{ ConfirmDialog \} from '\.\/components\/ui\/confirm-dialog';/);
|
||||
assert.match(appSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
|
||||
assert.doesNotMatch(functionBody(appSource, "handleDeleteHost"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
|
||||
|
||||
assert.match(aiSettingsSource, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
|
||||
assert.match(aiSettingsSource, /<ConfirmDialog[\s\S]*confirm\.removeProvider/);
|
||||
assert.doesNotMatch(functionBody(aiSettingsSource, "handleRemoveProvider"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
|
||||
});
|
||||
@@ -5,8 +5,16 @@
|
||||
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { formatProxyConfigEndpoint, formatProxyConfigType, isProxyCommandConfig, isValidProxyPort } from '../../domain/proxyProfiles';
|
||||
import { ProxyConfig, ProxyProfile } from '../../types';
|
||||
import {
|
||||
formatProxyConfigEndpoint,
|
||||
formatProxyConfigType,
|
||||
hasIncompleteProxyIdentity,
|
||||
hasMissingProxyIdentity,
|
||||
hasUnreadableProxyCredential,
|
||||
isProxyCommandConfig,
|
||||
isValidProxyPort,
|
||||
} from '../../domain/proxyProfiles';
|
||||
import { Identity, ProxyConfig, ProxyProfile } from '../../types';
|
||||
import { AsidePanel, AsidePanelContent, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -17,8 +25,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
||||
export interface ProxyPanelProps {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: string | number) => void;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
@@ -31,6 +40,7 @@ export type ProxyPanelPropsWithResize = ProxyPanelProps & AsidePanelResizeProps;
|
||||
export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyConfig,
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
@@ -56,11 +66,24 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
|
||||
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
|
||||
const canSave = isUsingProfile || (hasManualProxyValue && !hasInvalidManualProxyPort);
|
||||
const effectiveProxyConfig = selectedProfile?.config ?? proxyConfig;
|
||||
const hasMissingIdentity = hasMissingProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
() => identities.find((identity) => identity.id === proxyConfig?.identityId),
|
||||
[identities, proxyConfig?.identityId],
|
||||
);
|
||||
const selectedIdentityValue = selectedIdentity?.id || (hasMissingIdentity ? missingIdentityValue : manualCredentialsValue);
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasInvalidManualProxyPort) return;
|
||||
if (hasInvalidManualProxyPort || hasInvalidIdentity) return;
|
||||
onBack();
|
||||
}, [hasInvalidManualProxyPort, onBack]);
|
||||
}, [hasInvalidManualProxyPort, hasInvalidIdentity, onBack]);
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
@@ -122,6 +145,21 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasMissingIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasIncompleteIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.incompleteIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && hasUnreadableIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.unreadableIdentity')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -202,21 +240,90 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
value={proxyConfig?.username || ""}
|
||||
onChange={(e) => onUpdateProxy('username', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
type="password"
|
||||
value={proxyConfig?.password || ""}
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
{identities.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
</p>
|
||||
<Select
|
||||
value={selectedIdentityValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === missingIdentityValue) return;
|
||||
onUpdateProxy(
|
||||
'identityId',
|
||||
value === manualCredentialsValue ? undefined : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={manualCredentialsValue}>
|
||||
{t('hostDetails.proxyPanel.manualCredentials')}
|
||||
</SelectItem>
|
||||
{hasMissingIdentity && (
|
||||
<SelectItem value={missingIdentityValue}>
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</SelectItem>
|
||||
)}
|
||||
{identities.map((identity) => (
|
||||
<SelectItem key={identity.id} value={identity.id}>
|
||||
{identity.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{hasMissingIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{hasIncompleteIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.incompleteIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{hasUnreadableIdentity && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.unreadableIdentity')}
|
||||
</div>
|
||||
)}
|
||||
{selectedIdentity ? (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t('hostDetails.proxyPanel.keychainIdentity')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedIdentity.label} - {selectedIdentity.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
value={proxyConfig?.username || ""}
|
||||
onChange={(e) => onUpdateProxy('username', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
type="password"
|
||||
value={proxyConfig?.password || ""}
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -46,29 +46,6 @@ const HOST_ICON_COMPONENTS = {
|
||||
"server-cog": ServerCog,
|
||||
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
|
||||
|
||||
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
|
||||
server: "hostDetails.icon.option.server",
|
||||
terminal: "hostDetails.icon.option.terminal",
|
||||
database: "hostDetails.icon.option.database",
|
||||
cloud: "hostDetails.icon.option.cloud",
|
||||
router: "hostDetails.icon.option.router",
|
||||
shield: "hostDetails.icon.option.shield",
|
||||
code: "hostDetails.icon.option.code",
|
||||
box: "hostDetails.icon.option.box",
|
||||
globe: "hostDetails.icon.option.globe",
|
||||
cpu: "hostDetails.icon.option.cpu",
|
||||
"hard-drive": "hostDetails.icon.option.hard-drive",
|
||||
network: "hostDetails.icon.option.network",
|
||||
wifi: "hostDetails.icon.option.wifi",
|
||||
lock: "hostDetails.icon.option.lock",
|
||||
key: "hostDetails.icon.option.key",
|
||||
monitor: "hostDetails.icon.option.monitor",
|
||||
container: "hostDetails.icon.option.container",
|
||||
activity: "hostDetails.icon.option.activity",
|
||||
zap: "hostDetails.icon.option.zap",
|
||||
"server-cog": "hostDetails.icon.option.server-cog",
|
||||
};
|
||||
|
||||
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
|
||||
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
|
||||
return <Icon className={className} />;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
import { LazyMessageResponse } from "../ai-elements/LazyMessageResponse";
|
||||
|
||||
export function MarkdownPreview({
|
||||
content,
|
||||
emptyText,
|
||||
}: {
|
||||
content: string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyMessageResponse className="text-sm leading-relaxed text-foreground/90">
|
||||
{trimmed}
|
||||
</LazyMessageResponse>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
VaultTreeInlineRenameInput,
|
||||
VaultTreeItemRow,
|
||||
} from "../vault/VaultTreeRow";
|
||||
import { VaultDeleteConfirmDialog } from "../vault/VaultDeleteConfirmDialog";
|
||||
import {
|
||||
clearVaultDropIndicator,
|
||||
getVaultDropIntent,
|
||||
@@ -126,7 +127,7 @@ export interface NotesManagerProps {
|
||||
}
|
||||
|
||||
type HoverActionMenuProps = {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode | ((closeMenu: () => void) => React.ReactNode);
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -148,6 +149,11 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ children, className }
|
||||
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
const closeMenu = () => {
|
||||
cancelClose();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownTrigger asChild toggleOnClick={false}>
|
||||
@@ -179,7 +185,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ children, className }
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
{children}
|
||||
{typeof children === "function" ? children(closeMenu) : children}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -362,6 +368,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
const [isTreeResizing, setIsTreeResizing] = useState(false);
|
||||
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null);
|
||||
const [draggingGroupPath, setDraggingGroupPath] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "note" | "group";
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
|
||||
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
@@ -710,7 +721,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
setOverlayNoteId(nextSelection.overlayNoteId);
|
||||
};
|
||||
|
||||
const deleteNoteById = (noteId: string) => {
|
||||
const performDeleteNoteById = (noteId: string) => {
|
||||
const next = sortedNotes.filter((note) => note.id !== noteId);
|
||||
commitNotes(next);
|
||||
if (selectedNoteId === noteId) {
|
||||
@@ -723,6 +734,15 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
if (overlayNoteId === noteId) setOverlayNoteId(null);
|
||||
};
|
||||
|
||||
const requestDeleteNoteById = (noteId: string) => {
|
||||
const note = sortedNotes.find((item) => item.id === noteId);
|
||||
setDeleteTarget({
|
||||
type: "note",
|
||||
id: noteId,
|
||||
name: note?.title || t("notes.title.placeholder"),
|
||||
});
|
||||
};
|
||||
|
||||
const startCreateGroup = () => {
|
||||
const targetGroup = getNoteActionTargetGroup(selectedNote, selectedGroup);
|
||||
setCreatingGroupParent(targetGroup);
|
||||
@@ -771,13 +791,31 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = (group: string) => {
|
||||
const performDeleteGroup = (group: string) => {
|
||||
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
|
||||
commitNotes(sortedNotes.map((note) => isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note));
|
||||
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
|
||||
setEditingGroupPath(null);
|
||||
};
|
||||
|
||||
const requestDeleteGroup = (group: string) => {
|
||||
setDeleteTarget({
|
||||
type: "group",
|
||||
id: group,
|
||||
name: group,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDeleteTarget = () => {
|
||||
if (!deleteTarget) return;
|
||||
if (deleteTarget.type === "note") {
|
||||
performDeleteNoteById(deleteTarget.id);
|
||||
} else {
|
||||
performDeleteGroup(deleteTarget.id);
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const resetTreeDragState = () => {
|
||||
setDraggingNoteId(null);
|
||||
setDraggingGroupPath(null);
|
||||
@@ -904,7 +942,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
resetTreeDragState();
|
||||
};
|
||||
|
||||
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context") => {
|
||||
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => {
|
||||
const actions = [
|
||||
{
|
||||
label: t("common.rename"),
|
||||
@@ -920,7 +958,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
},
|
||||
{
|
||||
label: t("action.delete"),
|
||||
action: () => deleteNoteById(note.id),
|
||||
action: () => requestDeleteNoteById(note.id),
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
@@ -946,6 +984,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
closeMenu?.();
|
||||
action.action();
|
||||
}}
|
||||
>
|
||||
@@ -954,7 +993,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
));
|
||||
};
|
||||
|
||||
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context") => {
|
||||
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context", closeMenu?: () => void) => {
|
||||
const actions = [
|
||||
{
|
||||
label: t("notes.action.newNote"),
|
||||
@@ -981,7 +1020,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
},
|
||||
{
|
||||
label: t("action.delete"),
|
||||
action: () => deleteGroup(groupPath),
|
||||
action: () => requestDeleteGroup(groupPath),
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
@@ -1007,6 +1046,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
closeMenu?.();
|
||||
action.action();
|
||||
}}
|
||||
>
|
||||
@@ -1043,7 +1083,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return (
|
||||
<ContextMenu key={note.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<VaultTreeItemRow
|
||||
<VaultTreeItemRow
|
||||
label={noteDisplayTitle(note.title)}
|
||||
depth={depth}
|
||||
selected={selectedNoteId === note.id}
|
||||
@@ -1094,11 +1134,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
setSelectedGroup(nextSelection.selectedGroup);
|
||||
setOverlayNoteId(nextSelection.overlayNoteId);
|
||||
}}
|
||||
actions={(
|
||||
<HoverActionMenu>
|
||||
{renderNoteActions(note, "dropdown")}
|
||||
</HoverActionMenu>
|
||||
)}
|
||||
actions={(
|
||||
<HoverActionMenu>
|
||||
{(closeMenu) => renderNoteActions(note, "dropdown", closeMenu)}
|
||||
</HoverActionMenu>
|
||||
)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent data-notes-context-menu="note">
|
||||
@@ -1196,11 +1236,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
setOverlayNoteId(nextSelection.overlayNoteId);
|
||||
if (hasChildren) toggleGroup(node.path);
|
||||
}}
|
||||
actions={(
|
||||
<HoverActionMenu>
|
||||
{renderGroupActions(node.path, "dropdown")}
|
||||
</HoverActionMenu>
|
||||
)}
|
||||
actions={(
|
||||
<HoverActionMenu>
|
||||
{(closeMenu) => renderGroupActions(node.path, "dropdown", closeMenu)}
|
||||
</HoverActionMenu>
|
||||
)}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent data-notes-context-menu="group">
|
||||
@@ -1601,6 +1641,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<VaultDeleteConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title={t("vault.deleteConfirm.title", {
|
||||
name: deleteTarget?.name ?? "",
|
||||
})}
|
||||
description={
|
||||
deleteTarget?.type === "group"
|
||||
? t("vault.deleteConfirm.noteGroupDesc")
|
||||
: t("vault.deleteConfirm.desc")
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,529 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { ScriptDialogRequest } from "../../types/global/netcatty-bridge-script.d.ts";
|
||||
import {
|
||||
applyFormValue,
|
||||
getDialogFieldDomId,
|
||||
getInitialFormValues,
|
||||
getVisibleDialogFormFields,
|
||||
normalizeDialogFormSubmitValues,
|
||||
ScriptDialogFormBody,
|
||||
ScriptDialogFormFields,
|
||||
validateDialogFormValues,
|
||||
} from "./ScriptDialogHost.tsx";
|
||||
|
||||
const formRequest: ScriptDialogRequest = {
|
||||
requestId: "dialog-1",
|
||||
type: "form",
|
||||
message: "Choose options",
|
||||
form: {
|
||||
title: "Deploy",
|
||||
message: "Choose options",
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "env",
|
||||
label: "Environment",
|
||||
options: [
|
||||
{ label: "Development", value: "dev" },
|
||||
{ label: "Production", value: "prod", description: "Use carefully" },
|
||||
],
|
||||
defaultValue: "dev",
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "restart",
|
||||
label: "Restart service",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
name: "mode",
|
||||
label: "Mode",
|
||||
options: [
|
||||
{ label: "Safe", value: "safe" },
|
||||
{ label: "Fast", value: "fast" },
|
||||
],
|
||||
defaultValue: "safe",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "notes",
|
||||
label: "Notes",
|
||||
defaultValue: "initial note",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "retries",
|
||||
label: "Retries",
|
||||
defaultValue: 3,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test("script dialog form derives initial values from fields", () => {
|
||||
assert.deepEqual(getInitialFormValues(formRequest), {
|
||||
env: "dev",
|
||||
restart: true,
|
||||
mode: "safe",
|
||||
notes: "initial note",
|
||||
retries: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form value helper preserves previous values for submit payload", () => {
|
||||
const initial = getInitialFormValues(formRequest);
|
||||
const withEnv = applyFormValue(initial, "env", "prod");
|
||||
const withRestart = applyFormValue(withEnv, "restart", false);
|
||||
const withMode = applyFormValue(withRestart, "mode", "fast");
|
||||
const withNotes = applyFormValue(withMode, "notes", "ship it");
|
||||
const submitted = normalizeDialogFormSubmitValues(
|
||||
formRequest.form!,
|
||||
applyFormValue(withNotes, "retries", "5"),
|
||||
);
|
||||
|
||||
assert.deepEqual(submitted, {
|
||||
env: "prod",
|
||||
restart: false,
|
||||
mode: "fast",
|
||||
notes: "ship it",
|
||||
retries: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form validates required text and number fields", () => {
|
||||
const emptyRequiredRequest: ScriptDialogRequest = {
|
||||
...formRequest,
|
||||
form: {
|
||||
...formRequest.form!,
|
||||
fields: [
|
||||
...formRequest.form!.fields,
|
||||
{ type: "textarea", name: "requiredNotes", label: "Required notes", defaultValue: "" },
|
||||
{ type: "number", name: "requiredCount", label: "Required count" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const values = getInitialFormValues(emptyRequiredRequest);
|
||||
|
||||
assert.deepEqual(validateDialogFormValues(emptyRequiredRequest.form!, values, "Required"), {
|
||||
requiredNotes: "Required",
|
||||
requiredCount: "Required",
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form only requires checkboxes when explicitly marked required", () => {
|
||||
const form = {
|
||||
message: "Confirm",
|
||||
fields: [
|
||||
{
|
||||
type: "checkbox" as const,
|
||||
name: "optionalFlag",
|
||||
label: "Optional flag",
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
type: "checkbox" as const,
|
||||
name: "confirmDanger",
|
||||
label: "I understand",
|
||||
defaultValue: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.deepEqual(validateDialogFormValues(form, {
|
||||
optionalFlag: false,
|
||||
confirmDanger: false,
|
||||
}, "Required"), {
|
||||
confirmDanger: "Required",
|
||||
});
|
||||
assert.deepEqual(validateDialogFormValues(form, {
|
||||
optionalFlag: false,
|
||||
confirmDanger: true,
|
||||
}, "Required"), {});
|
||||
});
|
||||
|
||||
test("script dialog form validates number min max and step before submit", () => {
|
||||
const form = {
|
||||
message: "Number limits",
|
||||
fields: [{
|
||||
type: "number" as const,
|
||||
name: "delayMs",
|
||||
label: "Delay",
|
||||
defaultValue: 500,
|
||||
min: 0,
|
||||
max: 5000,
|
||||
step: 100,
|
||||
required: false,
|
||||
}],
|
||||
};
|
||||
const messages = {
|
||||
required: "Required",
|
||||
numberInvalid: "Invalid",
|
||||
numberMin: (min: number) => `Min ${min}`,
|
||||
numberMax: (max: number) => `Max ${max}`,
|
||||
numberStep: (step: number) => `Step ${step}`,
|
||||
};
|
||||
|
||||
assert.deepEqual(validateDialogFormValues(form, { delayMs: -1 }, messages), {
|
||||
delayMs: "Min 0",
|
||||
});
|
||||
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5001 }, messages), {
|
||||
delayMs: "Max 5000",
|
||||
});
|
||||
assert.deepEqual(validateDialogFormValues(form, { delayMs: 550 }, messages), {
|
||||
delayMs: "Step 100",
|
||||
});
|
||||
assert.deepEqual(validateDialogFormValues(form, { delayMs: "" }, messages), {});
|
||||
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5000 }, messages), {});
|
||||
|
||||
const defaultBasedForm = {
|
||||
message: "Number limits",
|
||||
fields: [{
|
||||
type: "number" as const,
|
||||
name: "oddCount",
|
||||
label: "Odd count",
|
||||
defaultValue: 5,
|
||||
step: 2,
|
||||
}],
|
||||
};
|
||||
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 5 }, messages), {});
|
||||
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 7 }, messages), {});
|
||||
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 6 }, messages), {
|
||||
oddCount: "Step 2",
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form applies visibleWhen to rendering validation and submit payload", () => {
|
||||
const conditionalRequest: ScriptDialogRequest = {
|
||||
...formRequest,
|
||||
form: {
|
||||
...formRequest.form!,
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "target",
|
||||
label: "Target",
|
||||
options: [
|
||||
{ label: "Local", value: "local" },
|
||||
{ label: "Remote", value: "remote" },
|
||||
],
|
||||
defaultValue: "local",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "host",
|
||||
label: "Remote host",
|
||||
defaultValue: "",
|
||||
visibleWhen: { field: "target", equals: "remote" },
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "confirmRemote",
|
||||
label: "Confirm remote",
|
||||
defaultValue: false,
|
||||
visibleWhen: { field: "target", notEquals: "local" },
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "localNote",
|
||||
label: "Local note",
|
||||
defaultValue: "local only",
|
||||
required: false,
|
||||
visibleWhen: { field: "target", equals: "local" },
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "remoteDetail",
|
||||
label: "Remote detail",
|
||||
defaultValue: "hidden by hidden controller",
|
||||
required: false,
|
||||
visibleWhen: { field: "confirmRemote", truthy: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const localValues = getInitialFormValues(conditionalRequest);
|
||||
const localVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, localValues).map((field) => field.name);
|
||||
|
||||
assert.deepEqual(localVisibleNames, ["target", "localNote"]);
|
||||
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, localValues, "Required"), {});
|
||||
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, localValues), {
|
||||
target: "local",
|
||||
localNote: "local only",
|
||||
});
|
||||
|
||||
const remoteValues = applyFormValue(applyFormValue(localValues, "target", "remote"), "confirmRemote", true);
|
||||
const remoteVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, remoteValues).map((field) => field.name);
|
||||
|
||||
assert.deepEqual(remoteVisibleNames, ["target", "host", "confirmRemote", "remoteDetail"]);
|
||||
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, remoteValues, "Required"), {
|
||||
host: "Required",
|
||||
});
|
||||
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, applyFormValue(remoteValues, "host", "example.com")), {
|
||||
target: "remote",
|
||||
host: "example.com",
|
||||
confirmRemote: true,
|
||||
remoteDetail: "hidden by hidden controller",
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form does not show fields chained from hidden controllers", () => {
|
||||
const request: ScriptDialogRequest = {
|
||||
...formRequest,
|
||||
form: {
|
||||
...formRequest.form!,
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "target",
|
||||
label: "Target",
|
||||
options: [
|
||||
{ label: "Local", value: "local" },
|
||||
{ label: "Remote", value: "remote" },
|
||||
],
|
||||
defaultValue: "local",
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "advanced",
|
||||
label: "Advanced",
|
||||
defaultValue: true,
|
||||
visibleWhen: { field: "target", equals: "remote" },
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "advancedNote",
|
||||
label: "Advanced note",
|
||||
defaultValue: "should stay hidden",
|
||||
visibleWhen: { field: "advanced", truthy: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
getVisibleDialogFormFields(request.form!, getInitialFormValues(request)).map((field) => field.name),
|
||||
["target"],
|
||||
);
|
||||
assert.deepEqual(normalizeDialogFormSubmitValues(request.form!, getInitialFormValues(request)), {
|
||||
target: "local",
|
||||
});
|
||||
});
|
||||
|
||||
test("script dialog form fields render select checkbox radio textarea and number controls", () => {
|
||||
const values = applyFormValue(getInitialFormValues(formRequest), "env", "prod");
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={formRequest.form!}
|
||||
formValues={values}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Environment/);
|
||||
assert.match(markup, /Use carefully/);
|
||||
assert.match(markup, /id="script-dialog-env-label"/);
|
||||
assert.match(markup, /aria-labelledby="script-dialog-env-label"/);
|
||||
assert.match(markup, /Restart service/);
|
||||
assert.match(markup, /type="checkbox"[^>]*checked=""/);
|
||||
assert.match(markup, /type="radio"[^>]*checked=""[^>]*value="safe"/);
|
||||
assert.match(markup, /type="radio"[^>]*value="fast"/);
|
||||
assert.match(markup, /<textarea[^>]*>initial note<\/textarea>/);
|
||||
assert.match(markup, /type="number"[^>]*min="0"[^>]*step="1"[^>]*value="3"/);
|
||||
});
|
||||
|
||||
test("script dialog form fields do not render hidden visibleWhen controls", () => {
|
||||
const request: ScriptDialogRequest = {
|
||||
...formRequest,
|
||||
form: {
|
||||
...formRequest.form!,
|
||||
fields: [
|
||||
{
|
||||
type: "select",
|
||||
name: "target",
|
||||
label: "Target",
|
||||
options: [
|
||||
{ label: "Local", value: "local" },
|
||||
{ label: "Remote", value: "remote" },
|
||||
],
|
||||
defaultValue: "local",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
name: "host",
|
||||
label: "Remote host",
|
||||
defaultValue: "",
|
||||
visibleWhen: { field: "target", equals: "remote" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={request.form!}
|
||||
formValues={getInitialFormValues(request)}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /Target/);
|
||||
assert.doesNotMatch(markup, /Remote host/);
|
||||
});
|
||||
|
||||
test("script dialog form body renders fields inside a constrained scroll area", () => {
|
||||
const values = getInitialFormValues(formRequest);
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormBody
|
||||
form={formRequest.form!}
|
||||
formValues={values}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /data-radix-scroll-area-viewport/);
|
||||
assert.match(markup, /min-h-0/);
|
||||
assert.match(markup, /Environment/);
|
||||
});
|
||||
|
||||
test("script dialog form fields render required errors", () => {
|
||||
const values = getInitialFormValues(formRequest);
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={formRequest.form!}
|
||||
formValues={values}
|
||||
formErrors={{ retries: "Required" }}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-invalid="true"/);
|
||||
assert.match(markup, /id="script-dialog-retries-error"/);
|
||||
assert.match(markup, /aria-describedby="script-dialog-retries-error"/);
|
||||
assert.match(markup, /Required/);
|
||||
});
|
||||
|
||||
test("script dialog form fields associate checkbox errors with the input", () => {
|
||||
const form = {
|
||||
message: "Confirm",
|
||||
fields: [{
|
||||
type: "checkbox" as const,
|
||||
name: "confirmDanger",
|
||||
label: "I understand",
|
||||
description: "Required before continuing",
|
||||
defaultValue: false,
|
||||
required: true,
|
||||
}],
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={form}
|
||||
formValues={{ confirmDanger: false }}
|
||||
formErrors={{ confirmDanger: "Required" }}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /id="script-dialog-confirmDanger-description"/);
|
||||
assert.match(markup, /id="script-dialog-confirmDanger-error"/);
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-describedby="script-dialog-confirmDanger-description script-dialog-confirmDanger-error"/,
|
||||
);
|
||||
assert.match(markup, /aria-invalid="true"/);
|
||||
});
|
||||
|
||||
test("script dialog form field DOM ids encode names with spaces", () => {
|
||||
const form = {
|
||||
message: "Confirm",
|
||||
fields: [{
|
||||
type: "checkbox" as const,
|
||||
name: "confirm danger",
|
||||
label: "I understand",
|
||||
description: "Required before continuing",
|
||||
defaultValue: false,
|
||||
required: true,
|
||||
}],
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={form}
|
||||
formValues={{ "confirm danger": false }}
|
||||
formErrors={{ "confirm danger": "Required" }}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(getDialogFieldDomId("confirm danger"), "script-dialog-confirm%20danger");
|
||||
assert.match(markup, /id="script-dialog-confirm%20danger"/);
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-describedby="script-dialog-confirm%20danger-description script-dialog-confirm%20danger-error"/,
|
||||
);
|
||||
assert.doesNotMatch(markup, /script-dialog-confirm danger/);
|
||||
});
|
||||
|
||||
test("script dialog form control ids encode spaced names for radio text and number fields", () => {
|
||||
const form = {
|
||||
message: "Spaced names",
|
||||
fields: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
name: "run mode",
|
||||
label: "Run mode",
|
||||
description: "Choose mode",
|
||||
options: [
|
||||
{ label: "Safe", value: "safe" },
|
||||
{ label: "Fast", value: "fast" },
|
||||
],
|
||||
defaultValue: "safe",
|
||||
},
|
||||
{
|
||||
type: "textarea" as const,
|
||||
name: "release notes",
|
||||
label: "Release notes",
|
||||
defaultValue: "",
|
||||
},
|
||||
{
|
||||
type: "number" as const,
|
||||
name: "retry count",
|
||||
label: "Retry count",
|
||||
defaultValue: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
<ScriptDialogFormFields
|
||||
form={form}
|
||||
formValues={{
|
||||
"run mode": "safe",
|
||||
"release notes": "",
|
||||
"retry count": 3,
|
||||
}}
|
||||
formErrors={{
|
||||
"run mode": "Required",
|
||||
"release notes": "Required",
|
||||
"retry count": "Required",
|
||||
}}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /id="script-dialog-run%20mode-0"/);
|
||||
assert.match(markup, /name="script-dialog-run%20mode"/);
|
||||
assert.match(markup, /id="script-dialog-release%20notes"/);
|
||||
assert.match(markup, /for="script-dialog-release%20notes"/);
|
||||
assert.match(markup, /id="script-dialog-retry%20count"/);
|
||||
assert.match(markup, /for="script-dialog-retry%20count"/);
|
||||
assert.doesNotMatch(markup, /script-dialog-run mode/);
|
||||
assert.doesNotMatch(markup, /script-dialog-release notes/);
|
||||
assert.doesNotMatch(markup, /script-dialog-retry count/);
|
||||
});
|
||||
@@ -10,18 +10,432 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
import type { ScriptDialogRequest } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type {
|
||||
ScriptDialogCondition,
|
||||
ScriptDialogField,
|
||||
ScriptDialogForm,
|
||||
ScriptDialogFormValue,
|
||||
ScriptDialogRequest,
|
||||
} from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
|
||||
type FormValues = Record<string, ScriptDialogFormValue>;
|
||||
type FormErrors = Record<string, string>;
|
||||
type FormValidationMessages = string | {
|
||||
required: string;
|
||||
numberInvalid: string;
|
||||
numberMin: (min: number) => string;
|
||||
numberMax: (max: number) => string;
|
||||
numberStep: (step: number) => string;
|
||||
};
|
||||
|
||||
export function getInitialFormValues(request: ScriptDialogRequest): FormValues {
|
||||
if (request.type !== 'form' || !request.form) return {};
|
||||
return Object.fromEntries(
|
||||
request.form.fields.map((field) => [field.name, field.defaultValue]),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyFormValue(values: FormValues, name: string, value: ScriptDialogFormValue): FormValues {
|
||||
return { ...values, [name]: value };
|
||||
}
|
||||
|
||||
export function getDialogFieldDomId(name: string): string {
|
||||
return `script-dialog-${encodeURIComponent(name || 'field')}`;
|
||||
}
|
||||
|
||||
function resolveValidationMessages(messages: FormValidationMessages = 'Required') {
|
||||
if (typeof messages === 'string') {
|
||||
return {
|
||||
required: messages,
|
||||
numberInvalid: messages,
|
||||
numberMin: () => messages,
|
||||
numberMax: () => messages,
|
||||
numberStep: () => messages,
|
||||
};
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function matchesNumberStep(value: number, step: number, base = 0) {
|
||||
const quotient = (value - base) / step;
|
||||
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
|
||||
}
|
||||
|
||||
function getNumberStepBase(field: Extract<ScriptDialogField, { type: 'number' }>) {
|
||||
return field.min ?? field.defaultValue ?? 0;
|
||||
}
|
||||
|
||||
function getConditionFieldValue(
|
||||
form: ScriptDialogForm,
|
||||
values: FormValues,
|
||||
fieldName: string,
|
||||
): ScriptDialogFormValue {
|
||||
const field = form.fields.find((candidate) => candidate.name === fieldName);
|
||||
const value = values[fieldName];
|
||||
if (field?.type === 'number') {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : value;
|
||||
}
|
||||
if (field?.type === 'checkbox') {
|
||||
return Boolean(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function evaluateDialogCondition(
|
||||
form: ScriptDialogForm,
|
||||
values: FormValues,
|
||||
condition: ScriptDialogCondition,
|
||||
): boolean {
|
||||
const value = getConditionFieldValue(form, values, condition.field);
|
||||
if ('equals' in condition) {
|
||||
return value === condition.equals;
|
||||
}
|
||||
if ('notEquals' in condition) {
|
||||
return value !== condition.notEquals;
|
||||
}
|
||||
if ('truthy' in condition) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return !value;
|
||||
}
|
||||
|
||||
export function isDialogFieldVisible(
|
||||
form: ScriptDialogForm,
|
||||
field: ScriptDialogField,
|
||||
values: FormValues,
|
||||
): boolean {
|
||||
return getVisibleDialogFormFields(form, values).some((visibleField) => visibleField.name === field.name);
|
||||
}
|
||||
|
||||
export function getVisibleDialogFormFields(form: ScriptDialogForm, values: FormValues): ScriptDialogField[] {
|
||||
const visibleFields: ScriptDialogField[] = [];
|
||||
const visibleNames = new Set<string>();
|
||||
for (const field of form.fields) {
|
||||
if (!field.visibleWhen) {
|
||||
visibleFields.push(field);
|
||||
visibleNames.add(field.name);
|
||||
continue;
|
||||
}
|
||||
if (!visibleNames.has(field.visibleWhen.field)) {
|
||||
continue;
|
||||
}
|
||||
if (evaluateDialogCondition(form, values, field.visibleWhen)) {
|
||||
visibleFields.push(field);
|
||||
visibleNames.add(field.name);
|
||||
}
|
||||
}
|
||||
return visibleFields;
|
||||
}
|
||||
|
||||
export function validateDialogFormValues(
|
||||
form: ScriptDialogForm,
|
||||
values: FormValues,
|
||||
messages: FormValidationMessages = 'Required',
|
||||
): FormErrors {
|
||||
const validationMessages = resolveValidationMessages(messages);
|
||||
const errors: FormErrors = {};
|
||||
for (const field of getVisibleDialogFormFields(form, values)) {
|
||||
const value = values[field.name];
|
||||
if (field.type === 'checkbox') {
|
||||
if (field.required === true && !value) {
|
||||
errors[field.name] = validationMessages.required;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
const isEmpty = value === undefined || value === '';
|
||||
if (isEmpty) {
|
||||
if (field.required !== false) {
|
||||
errors[field.name] = validationMessages.required;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
errors[field.name] = validationMessages.numberInvalid;
|
||||
continue;
|
||||
}
|
||||
if (field.min !== undefined && numberValue < field.min) {
|
||||
errors[field.name] = validationMessages.numberMin(field.min);
|
||||
continue;
|
||||
}
|
||||
if (field.max !== undefined && numberValue > field.max) {
|
||||
errors[field.name] = validationMessages.numberMax(field.max);
|
||||
continue;
|
||||
}
|
||||
if (field.step !== undefined && !matchesNumberStep(numberValue, field.step, getNumberStepBase(field))) {
|
||||
errors[field.name] = validationMessages.numberStep(field.step);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (field.required === false) continue;
|
||||
if (value === undefined || String(value).trim() === '') {
|
||||
errors[field.name] = validationMessages.required;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function normalizeDialogFormSubmitValues(form: ScriptDialogForm, values: FormValues): FormValues {
|
||||
const next: FormValues = {};
|
||||
for (const field of getVisibleDialogFormFields(form, values)) {
|
||||
const value = values[field.name];
|
||||
if (field.type === 'number') {
|
||||
if (value === undefined || value === '') {
|
||||
next[field.name] = undefined;
|
||||
continue;
|
||||
}
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
next[field.name] = Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
continue;
|
||||
}
|
||||
next[field.name] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ScriptDialogFormFields({
|
||||
form,
|
||||
formValues,
|
||||
formErrors = {},
|
||||
onValueChange,
|
||||
}: {
|
||||
form: ScriptDialogForm;
|
||||
formValues: FormValues;
|
||||
formErrors?: FormErrors;
|
||||
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
|
||||
}) {
|
||||
const renderFormField = (field: ScriptDialogField) => {
|
||||
const inputId = getDialogFieldDomId(field.name);
|
||||
const fieldError = formErrors[field.name];
|
||||
const descriptionId = field.description ? `${inputId}-description` : undefined;
|
||||
const errorId = fieldError ? `${inputId}-error` : undefined;
|
||||
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
|
||||
const fieldDescription = field.description ? (
|
||||
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
|
||||
) : null;
|
||||
const inlineDescription = field.description ? (
|
||||
<span id={descriptionId} className="mt-1 block text-xs text-muted-foreground">{field.description}</span>
|
||||
) : null;
|
||||
const errorMessage = fieldError ? (
|
||||
<p id={errorId} className="text-xs text-destructive">{fieldError}</p>
|
||||
) : null;
|
||||
const inlineErrorMessage = fieldError ? (
|
||||
<span id={errorId} className="mt-1 block text-xs text-destructive">{fieldError}</span>
|
||||
) : null;
|
||||
|
||||
if (field.type === 'select') {
|
||||
const labelId = `${inputId}-label`;
|
||||
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
|
||||
const selectedOption = field.options.find((option) => option.value === selectedValue);
|
||||
const selectedDescriptionId = selectedOption?.description ? `${inputId}-selected-description` : undefined;
|
||||
const selectDescribedBy = [describedBy, selectedDescriptionId].filter(Boolean).join(' ') || undefined;
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label id={labelId} htmlFor={inputId}>{field.label}</Label>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onValueChange(field.name, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={inputId}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={selectDescribedBy}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
textValue={option.label}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedOption?.description ? (
|
||||
<p id={selectedDescriptionId} className="text-xs text-muted-foreground">{selectedOption.description}</p>
|
||||
) : null}
|
||||
{fieldDescription}
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'radio') {
|
||||
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
|
||||
return (
|
||||
<fieldset
|
||||
key={field.name}
|
||||
className="space-y-2"
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
>
|
||||
<legend className="text-sm font-medium leading-none">{field.label}</legend>
|
||||
{fieldDescription}
|
||||
<div className="space-y-2">
|
||||
{field.options.map((option, index) => {
|
||||
const optionInputId = `${inputId}-${index}`;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
htmlFor={optionInputId}
|
||||
className="flex items-start gap-2 rounded-md border border-border/60 px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
id={optionInputId}
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
disabled={option.disabled}
|
||||
onChange={(event) => onValueChange(field.name, event.target.value)}
|
||||
className="mt-0.5 h-4 w-4 accent-primary"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="block text-xs text-muted-foreground">{option.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errorMessage}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label htmlFor={inputId}>{field.label}</Label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={String(formValues[field.name] ?? field.defaultValue)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onValueChange(field.name, event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
/>
|
||||
{fieldDescription}
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<Label htmlFor={inputId}>{field.label}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
type="number"
|
||||
value={formValues[field.name] ?? field.defaultValue ?? ''}
|
||||
placeholder={field.placeholder}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
onChange={(event) => onValueChange(field.name, event.target.value)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
/>
|
||||
{fieldDescription}
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<label htmlFor={inputId} className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
checked={Boolean(formValues[field.name] ?? field.defaultValue)}
|
||||
onChange={(event) => onValueChange(field.name, event.target.checked)}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={fieldError ? true : undefined}
|
||||
className="mt-0.5 h-4 w-4 accent-primary"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium leading-none">{field.label}</span>
|
||||
{inlineDescription}
|
||||
{inlineErrorMessage}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{getVisibleDialogFormFields(form, formValues).map(renderFormField)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScriptDialogFormBody({
|
||||
form,
|
||||
formValues,
|
||||
formErrors,
|
||||
onValueChange,
|
||||
}: {
|
||||
form: ScriptDialogForm;
|
||||
formValues: FormValues;
|
||||
formErrors?: FormErrors;
|
||||
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="min-h-0 pr-3">
|
||||
<ScriptDialogFormFields
|
||||
form={form}
|
||||
formValues={formValues}
|
||||
formErrors={formErrors}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScriptDialogHost() {
|
||||
const { t } = useI18n();
|
||||
const [request, setRequest] = useState<ScriptDialogRequest | null>(null);
|
||||
const [promptValue, setPromptValue] = useState('');
|
||||
const [formValues, setFormValues] = useState<FormValues>({});
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => {
|
||||
setRequest(payload);
|
||||
setPromptValue(payload.defaultValue ?? '');
|
||||
setFormValues(getInitialFormValues(payload));
|
||||
setFormErrors({});
|
||||
});
|
||||
return dispose;
|
||||
}, []);
|
||||
@@ -34,6 +448,38 @@ export function ScriptDialogHost() {
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const form = request.type === 'form' ? request.form : undefined;
|
||||
const dialogTitle = request.type === 'waitForTimeout'
|
||||
? t('scripts.dialog.waitForTimeoutTitle')
|
||||
: form?.title || t('scripts.dialog.title');
|
||||
const message = form?.message ?? request.message;
|
||||
|
||||
const setFormValue = (name: string, value: ScriptDialogFormValue) => {
|
||||
setFormValues((current) => applyFormValue(current, name, value));
|
||||
setFormErrors((current) => {
|
||||
if (!current[name]) return current;
|
||||
const { [name]: _removed, ...rest } = current;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
if (!form) return;
|
||||
const errors = validateDialogFormValues(form, formValues, {
|
||||
required: t('scripts.dialog.required'),
|
||||
numberInvalid: t('scripts.dialog.numberInvalid'),
|
||||
numberMin: (min) => t('scripts.dialog.numberMin', { min }),
|
||||
numberMax: (max) => t('scripts.dialog.numberMax', { max }),
|
||||
numberStep: (step) => t('scripts.dialog.numberStep', { step }),
|
||||
});
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors);
|
||||
return;
|
||||
}
|
||||
const submitValues = normalizeDialogFormSubmitValues(form, formValues);
|
||||
void respond(submitValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@@ -41,14 +487,10 @@ export function ScriptDialogHost() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className={form ? 'max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden' : undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{request.type === 'waitForTimeout'
|
||||
? t('scripts.dialog.waitForTimeoutTitle')
|
||||
: t('scripts.dialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{request.message}</DialogDescription>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
{message ? <DialogDescription>{message}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
{request.type === 'prompt' ? (
|
||||
<Input
|
||||
@@ -57,6 +499,14 @@ export function ScriptDialogHost() {
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
{form ? (
|
||||
<ScriptDialogFormBody
|
||||
form={form}
|
||||
formValues={formValues}
|
||||
formErrors={formErrors}
|
||||
onValueChange={setFormValue}
|
||||
/>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
{request.type === 'waitForTimeout' ? (
|
||||
<>
|
||||
@@ -80,6 +530,15 @@ export function ScriptDialogHost() {
|
||||
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
|
||||
</>
|
||||
) : request.type === 'form' ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void respond(undefined, true)}>
|
||||
{form?.cancelLabel || t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={submitForm}>
|
||||
{form?.submitLabel || t('scripts.dialog.ok')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog";
|
||||
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
|
||||
import { AgentIconBadge } from "../../ai/AgentIconBadge";
|
||||
@@ -199,6 +200,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
} = useAIPermissionGrantsState();
|
||||
const { t } = useI18n();
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
||||
const [removeProviderConfirm, setRemoveProviderConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
|
||||
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
@@ -512,16 +514,22 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
(id: string) => {
|
||||
const provider = providers.find((p) => p.id === id);
|
||||
const name = provider?.name || id;
|
||||
const ok = window.confirm(
|
||||
t('confirm.removeProvider', { name }),
|
||||
);
|
||||
if (!ok) return;
|
||||
setRemoveProviderConfirm({ id, name });
|
||||
},
|
||||
[providers],
|
||||
);
|
||||
|
||||
const handleConfirmRemoveProvider = useCallback(
|
||||
() => {
|
||||
if (!removeProviderConfirm) return;
|
||||
const { id } = removeProviderConfirm;
|
||||
removeProvider(id);
|
||||
if (editingProviderId === id) {
|
||||
setEditingProviderId(null);
|
||||
}
|
||||
setRemoveProviderConfirm(null);
|
||||
},
|
||||
[removeProvider, editingProviderId, providers, t],
|
||||
[removeProvider, editingProviderId, removeProviderConfirm],
|
||||
);
|
||||
|
||||
// Agent options for default agent
|
||||
@@ -1173,6 +1181,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ConfirmDialog
|
||||
open={removeProviderConfirm !== null}
|
||||
title={removeProviderConfirm ? t('confirm.removeProvider', { name: removeProviderConfirm.name }) : ''}
|
||||
confirmLabel={t('action.remove')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveProviderConfirm(null);
|
||||
}}
|
||||
onConfirm={handleConfirmRemoveProvider}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
};
|
||||
|
||||
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user