Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
f413035295 | ||
|
|
bfd3fb4dad | ||
|
|
733e19a6f6 | ||
|
|
85b552e1a6 | ||
|
|
068730c53c | ||
|
|
c9d84c7ce3 | ||
|
|
d558aea7de | ||
|
|
e211eec693 | ||
|
|
6b1277d3e1 | ||
|
|
35bf38be70 | ||
|
|
555c00406e | ||
|
|
e67012654a | ||
|
|
ecdb1d17cd | ||
|
|
a5578b5e60 | ||
|
|
fb4641878f | ||
|
|
7d6f30f51f | ||
|
|
9869b645b1 | ||
|
|
037b85bd66 | ||
|
|
ba784b8b35 | ||
|
|
eae760db3f | ||
|
|
4b5993cad6 | ||
|
|
6af62aa093 | ||
|
|
61e8de4270 | ||
|
|
27dce4e427 | ||
|
|
8b53fb1c7b | ||
|
|
6c1661dc3c | ||
|
|
3662b45121 | ||
|
|
437253179e | ||
|
|
d85f4edbbb | ||
|
|
96c9ccaaa0 | ||
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 | ||
|
|
15ec02dcae | ||
|
|
e75c654a1a | ||
|
|
29b1eca1fd | ||
|
|
e2d036e710 | ||
|
|
094f0abe4a | ||
|
|
8ab2003dae | ||
|
|
b1b0c5648c | ||
|
|
36e5779d94 | ||
|
|
53aef452cc | ||
|
|
3ef5a64b94 | ||
|
|
c28db932a4 | ||
|
|
f2c2501fa5 | ||
|
|
b1f930a995 | ||
|
|
de60b616cd | ||
|
|
6e6a0240a7 | ||
|
|
2e2360a9fc | ||
|
|
8011f4e2e8 | ||
|
|
970037682c | ||
|
|
42b58efc5c | ||
|
|
b20163d762 | ||
|
|
e0a56cbb14 | ||
|
|
8dae851ea3 | ||
|
|
03ba9595c0 | ||
|
|
4b07b4826a | ||
|
|
80d9b33c59 | ||
|
|
3be3c14912 | ||
|
|
4171f85c73 | ||
|
|
5a78ebcf7c | ||
|
|
9294a7130f | ||
|
|
9ce3abc2b4 | ||
|
|
327594a598 | ||
|
|
31cccdec03 | ||
|
|
29a6172120 | ||
|
|
06486e06dd | ||
|
|
ada55ab461 | ||
|
|
a9e4de65a9 | ||
|
|
2867262e4d | ||
|
|
779c09186c | ||
|
|
6a0408b942 | ||
|
|
43e094c345 | ||
|
|
7d30b19421 | ||
|
|
e9e8c35178 |
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in Netcatty
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
|
||||
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Netcatty version
|
||||
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
|
||||
placeholder: "e.g. 1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: How did you install Netcatty?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
|
||||
- Homebrew
|
||||
- Built from source (npm run dev / pack)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH connection / terminal
|
||||
- SFTP / file browser
|
||||
- Host vault / keychain
|
||||
- Port forwarding
|
||||
- Snippets
|
||||
- AI assistant
|
||||
- Settings / sync
|
||||
- UI / layout
|
||||
- Crash / app won't start
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: reproducibility
|
||||
attributes:
|
||||
label: Can you reproduce it?
|
||||
options:
|
||||
- Always (100%)
|
||||
- Often (>50%)
|
||||
- Sometimes
|
||||
- Once / not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps so we can follow exactly.
|
||||
placeholder: |
|
||||
1. Open Netcatty and connect to host X
|
||||
2. Click SFTP tab
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots
|
||||
description: |
|
||||
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
|
||||
For SSH errors, include redacted connection details (no passwords / private keys).
|
||||
placeholder: Paste relevant log lines or attach screenshots.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate
|
||||
required: true
|
||||
- label: I removed passwords, private keys, and other secrets from this report
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & general help
|
||||
url: https://github.com/binaricat/Netcatty/discussions
|
||||
about: Not sure if it is a bug? Ask in Discussions first.
|
||||
- name: Latest release
|
||||
url: https://github.com/binaricat/Netcatty/releases/latest
|
||||
about: Check your Netcatty version before reporting.
|
||||
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new capability
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Describe the problem you are trying to solve and the change you want.
|
||||
Vague requests like "make it better" may be closed.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / pain point
|
||||
description: What is hard, missing, or frustrating today?
|
||||
placeholder: When I manage 50+ hosts, I cannot ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What would you like Netcatty to do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other tools, workarounds, or designs you thought about.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Related area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH / terminal
|
||||
- SFTP
|
||||
- Host vault / keychain
|
||||
- Port forwarding
|
||||
- Snippets
|
||||
- AI assistant
|
||||
- Settings / sync
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my daily workflow
|
||||
- Blocking / critical for my use case
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and discussions for similar requests
|
||||
required: true
|
||||
11
.github/scripts/generate-release-note.js
vendored
@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
// - pacman: x64 -> x64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -70,6 +71,10 @@ const files = {
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
},
|
||||
pacman: {
|
||||
x64: `Netcatty-${version}-linux-x64.pacman`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.pacman`
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -88,7 +93,9 @@ const badges = {
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`,
|
||||
pacman_x64: `[](${baseUrl}/${files.linux.pacman.x64})`,
|
||||
pacman_arm64: `[](${baseUrl}/${files.linux.pacman.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +106,7 @@ const content = `
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
|
||||
8
.github/workflows/build.yml
vendored
@@ -348,6 +348,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
@@ -410,6 +411,9 @@ 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: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -457,6 +461,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -510,6 +515,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
@@ -568,6 +574,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -673,6 +680,7 @@ jobs:
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.pacman
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
|
||||
139
.github/workflows/issue-format.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
name: issue-format
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
|
||||
if: >-
|
||||
github.event.issue.user.type != 'Bot' &&
|
||||
!contains(github.event.issue.labels.*.name, 'format-exempt')
|
||||
steps:
|
||||
- name: Validate title and body
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title.trim();
|
||||
const body = (issue.body || '').trim();
|
||||
const errors = [];
|
||||
|
||||
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
|
||||
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
|
||||
if (!modernTitle && !legacyAppTitle) {
|
||||
errors.push(
|
||||
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
|
||||
);
|
||||
}
|
||||
|
||||
if (body.length < 120) {
|
||||
errors.push(
|
||||
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
|
||||
);
|
||||
}
|
||||
|
||||
const templateMarkers = [
|
||||
'Steps to reproduce',
|
||||
'Expected behavior',
|
||||
'Actual behavior',
|
||||
'Describe the problem',
|
||||
'Problem / pain point',
|
||||
'Proposed solution',
|
||||
'Operating system',
|
||||
];
|
||||
const hasTemplateStructure = templateMarkers.some((marker) =>
|
||||
body.includes(marker)
|
||||
);
|
||||
if (!hasTemplateStructure) {
|
||||
errors.push(
|
||||
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
|
||||
);
|
||||
}
|
||||
|
||||
const labels = new Set(
|
||||
(issue.labels || []).map((label) =>
|
||||
typeof label === 'string' ? label : label.name
|
||||
)
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
if (
|
||||
issue.state === 'closed' &&
|
||||
labels.has('invalid-format')
|
||||
) {
|
||||
labels.delete('invalid-format');
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'open',
|
||||
labels: [...labels],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
|
||||
});
|
||||
}
|
||||
core.info('Issue format OK');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = issue.number;
|
||||
const marker = '<!-- issue-format-bot -->';
|
||||
const bodyText = [
|
||||
marker,
|
||||
'## Issue format check failed',
|
||||
'',
|
||||
'This issue was closed automatically because it does not follow the required format.',
|
||||
'',
|
||||
...errors.map((e) => `- ${e}`),
|
||||
'',
|
||||
'### How to resubmit',
|
||||
'',
|
||||
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
|
||||
'2. Pick **Bug Report** or **Feature Request**',
|
||||
'3. Fill in every required field',
|
||||
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
|
||||
'',
|
||||
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
|
||||
'',
|
||||
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyNotified = comments.some((c) =>
|
||||
(c.body || '').includes(marker)
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
labels.add('invalid-format');
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: [...labels],
|
||||
});
|
||||
146
App.tsx
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
@@ -28,9 +27,7 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -60,18 +57,24 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
|
||||
import { PassphraseRequest } from './components/PassphraseModal';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
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, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
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';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
sourceSession?: TerminalSession;
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
@@ -100,6 +103,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -125,13 +129,16 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
reapplyCurrentTheme,
|
||||
sessionLogsTimestampsEnabled,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
@@ -237,6 +244,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
@@ -244,6 +252,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
@@ -259,19 +268,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// Active tab lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
const activeTabId = useActiveTabId();
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
@@ -290,59 +291,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
// activeTabId-derived chrome (window title, sftp guard) is owned by
|
||||
// <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onOpenSessionInNewWindow) return undefined;
|
||||
return bridge.onOpenSessionInNewWindow((payload) => {
|
||||
if (!payload?.sourceSession) return;
|
||||
setPendingNewWindowSession(payload);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
|
||||
createSessionFromCloneSource(pendingNewWindowSession.sourceSession, {
|
||||
localShellType: pendingNewWindowSession.localShellType,
|
||||
});
|
||||
setPendingNewWindowSession(null);
|
||||
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
@@ -714,6 +681,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessionId, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells]);
|
||||
|
||||
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
|
||||
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (hotkeyScheme === 'disabled') return null;
|
||||
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
|
||||
@@ -728,12 +697,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
() => getOrderedWorkTabs(editorTabTopIds),
|
||||
[editorTabTopIds, getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
reorderTabs(draggedId, targetId, position, editorTabTopIds);
|
||||
}, [editorTabTopIds, reorderTabs]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
@@ -755,7 +737,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
|
||||
const intent = resolveWindowCommandCloseIntent({
|
||||
activeTabId,
|
||||
activeTabId: activeTabStore.getActiveTabId(),
|
||||
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
sessionIds: sessions.map((session) => session.id),
|
||||
workspaceIds: workspaces.map((workspace) => workspace.id),
|
||||
@@ -773,7 +755,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, [activeTabId, closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
|
||||
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
|
||||
@@ -985,7 +967,27 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
|
||||
|
||||
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, 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, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
|
||||
return (
|
||||
<>
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
applyAppTheme={applyAppTheme}
|
||||
hostById={hostById}
|
||||
sessionById={sessionById}
|
||||
themeById={themeById}
|
||||
workspaceById={workspaceById}
|
||||
currentTerminalTheme={currentTerminalTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
accentMode={accentMode}
|
||||
customAccent={customAccent}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
t={t}
|
||||
/>
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, 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, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppWithProviders() {
|
||||
|
||||
153
application/AppHandlers.newWindow.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
|
||||
|
||||
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod SSH",
|
||||
hostname: "prod.example.com",
|
||||
username: "deploy",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Prod SSH",
|
||||
sourceSession: sourceSession(),
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
called = true;
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"missing-session",
|
||||
);
|
||||
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => ({ success: false }),
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
142
application/app/AppActiveTabChrome.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
TerminalTheme,
|
||||
Workspace,
|
||||
} from '../../types';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
|
||||
interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
applyAppTheme: () => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
|
||||
* work derived from it: window title and the SFTP-tab guard.
|
||||
* Extracted out of <App> so that switching top tabs only
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
logViews,
|
||||
t,
|
||||
}: AppActiveTabChromeProps) {
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSftpTab && activeTabId === 'sftp') {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}), [
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
updateActiveChromeThemeDeps(chromeThemeDeps);
|
||||
|
||||
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
|
||||
...chromeThemeDeps,
|
||||
activeTabId,
|
||||
}), [chromeThemeDeps, activeTabId]);
|
||||
|
||||
useActiveChromeTheme({
|
||||
activeTheme: activeChromeTheme,
|
||||
applyAppTheme,
|
||||
});
|
||||
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
|
||||
return counts;
|
||||
}, [editorTabs]);
|
||||
|
||||
const activeWindowTitle = useMemo(() => {
|
||||
if (activeTabId === 'vault') return 'Vaults';
|
||||
if (activeTabId === 'sftp') return 'SFTP';
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
if (!editorTab) return 'Editor';
|
||||
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
return `${editorTab.fileName}${suffix}`;
|
||||
}
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) return workspace.title;
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (session) return session.hostLabel;
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
|
||||
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
|
||||
}
|
||||
return 'Netcatty';
|
||||
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
|
||||
|
||||
useEffect(() => {
|
||||
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
|
||||
}, [activeWindowTitle]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,11 +2,17 @@
|
||||
import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
const getLogHostVisualSnapshot = (host: Host) => ({
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
});
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
{
|
||||
@@ -65,6 +71,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -83,6 +90,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -203,7 +211,7 @@ export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, re
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +327,36 @@ export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessio
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
|
||||
{
|
||||
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
|
||||
if (!sourceSession) return false;
|
||||
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSessionInNewWindow) {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
try {
|
||||
const result = await bridge.openSessionInNewWindow({
|
||||
title: sourceSession.hostLabel,
|
||||
sourceSession,
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
||||
});
|
||||
const success = result?.success === true;
|
||||
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return success;
|
||||
} catch {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
|
||||
const { netcattyBridge, sessions, t } = getCtx();
|
||||
{
|
||||
@@ -678,6 +716,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -696,6 +735,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
|
||||
64
application/app/AppHostTreeLayer.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
shouldAutoOpenHostTreeOnSurfaceChange,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('shared host tree layer is visible above work tabs', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(true), {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree layer is hidden behind root pages', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(false), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree auto-opens when entering a work tab surface', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: true,
|
||||
previousSurfaceVisible: false,
|
||||
surfaceVisible: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('shared host tree does not force reopen while already on work tab surfaces', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: true,
|
||||
previousSurfaceVisible: true,
|
||||
surfaceVisible: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('shared host tree does not auto-open when disabled', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: false,
|
||||
previousSurfaceVisible: false,
|
||||
surfaceVisible: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
124
application/app/AppHostTreeLayer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { scheduleAfterInstantThemeSwitch } from '../state/useActiveChromeTheme';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
|
||||
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
visibility: surfaceVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: surfaceVisible ? 'auto' : 'none',
|
||||
zIndex: surfaceVisible ? 30 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled,
|
||||
previousSurfaceVisible,
|
||||
surfaceVisible,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
previousSurfaceVisible: boolean;
|
||||
surfaceVisible: boolean;
|
||||
}): boolean {
|
||||
return enabled && surfaceVisible && !previousSurfaceVisible;
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const previousSurfaceVisibleRef = useRef(false);
|
||||
const cancelAutoOpenRef = useRef<(() => void) | null>(null);
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
const surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
useEffect(() => {
|
||||
cancelAutoOpenRef.current?.();
|
||||
cancelAutoOpenRef.current = null;
|
||||
|
||||
const previousSurfaceVisible = previousSurfaceVisibleRef.current;
|
||||
previousSurfaceVisibleRef.current = surfaceVisible;
|
||||
if (shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled,
|
||||
previousSurfaceVisible,
|
||||
surfaceVisible,
|
||||
})) {
|
||||
cancelAutoOpenRef.current = scheduleAfterInstantThemeSwitch(() => {
|
||||
cancelAutoOpenRef.current = null;
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAutoOpenRef.current?.();
|
||||
cancelAutoOpenRef.current = null;
|
||||
};
|
||||
}, [enabled, surfaceVisible]);
|
||||
|
||||
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
data-section="app-host-tree-layer"
|
||||
style={getAppHostTreeLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<TerminalHostTreeSidebar
|
||||
enabled={enabled}
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
application/app/AppMounts.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden log view remains hidden while preserving host sidebar offset', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('terminal layer renders only after terminal content is visible or mounted', () => {
|
||||
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
@@ -29,14 +31,24 @@ interface LogViewWrapperProps {
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export function getLogViewWrapperStyle(
|
||||
isVisible: boolean,
|
||||
hostTreeLayoutWidth: number,
|
||||
): React.CSSProperties {
|
||||
const baseStyle = {
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
return isVisible
|
||||
? baseStyle
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
// Use same pattern as VaultViewContainer for visibility
|
||||
const containerStyle: React.CSSProperties = isVisible
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
|
||||
@@ -67,6 +79,13 @@ const LazyTerminalLayer = lazy(() =>
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
): boolean {
|
||||
return isVisible || shouldMount;
|
||||
}
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
@@ -85,7 +104,14 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
|
||||
const isVisible = isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}) || !!props.draggingSessionId;
|
||||
const [shouldMount, setShouldMount] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,7 +133,7 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -32,8 +32,8 @@ type AppViewContext = Record<string, any>;
|
||||
|
||||
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
const {
|
||||
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
@@ -42,11 +42,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
|
||||
@@ -106,10 +106,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -121,6 +120,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -128,18 +128,34 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<AppHostTreeLayer
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
@@ -214,6 +230,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
@@ -258,6 +275,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
@@ -267,12 +286,16 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
@@ -298,7 +321,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
<TextEditorTabView
|
||||
key={tab.id}
|
||||
tabId={tab.id}
|
||||
isVisible={activeTabId === toEditorTabId(tab.id)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
hostById={hostById}
|
||||
|
||||
106
application/app/activeChromeTheme.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { toEditorTabId } from "../state/activeTabStore.ts";
|
||||
import type { EditorTab } from "../state/editorTabStore.ts";
|
||||
import type { LogView } from "../state/logViewState.ts";
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background: type === "dark" ? "#111111" : "#eeeeee",
|
||||
foreground: type === "dark" ? "#eeeeee" : "#111111",
|
||||
cursor: "#22aaff",
|
||||
},
|
||||
});
|
||||
|
||||
const currentTheme = theme("current");
|
||||
const hostTheme = theme("host-theme");
|
||||
const logTheme = theme("log-theme", "light");
|
||||
|
||||
const baseInput = {
|
||||
accentMode: "theme" as const,
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: "221.2 83.2% 53.3%",
|
||||
editorTabs: [],
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map<string, Host>(),
|
||||
logViews: [],
|
||||
sessionById: new Map<string, TerminalSession>(),
|
||||
themeById: new Map([
|
||||
[currentTheme.id, currentTheme],
|
||||
[hostTheme.id, hostTheme],
|
||||
[logTheme.id, logTheme],
|
||||
]),
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the theme from their owning host", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "log-1",
|
||||
logViews: [{
|
||||
id: "log-1",
|
||||
connectionLogId: "1",
|
||||
log: { id: "1", themeId: logTheme.id },
|
||||
} as unknown as LogView],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, logTheme.id);
|
||||
});
|
||||
|
||||
test("root pages use the normal application theme", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "vault",
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map(),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
104
application/app/activeChromeTheme.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
logViews,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
}: Pick<
|
||||
ResolveActiveChromeThemeInput,
|
||||
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
|
||||
>): boolean {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return true;
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
}
|
||||
if (logViews.some((item) => item.id === activeTabId)) return true;
|
||||
if (workspaceById.has(activeTabId)) return true;
|
||||
if (sessionById.has(activeTabId)) return true;
|
||||
return false;
|
||||
}
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTab } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
export function resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(session.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
const host = hostById.get(editorTab.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (workspace.viewMode === "focus") {
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const focusedSession = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const workspaceSessions = collectSessionIds(workspace.root)
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (workspaceSessions.length === 0) return null;
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
return session ? resolveSessionTheme(session) : null;
|
||||
}
|
||||
18
application/app/topTabsChromeTheme.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
|
||||
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
109
application/app/topTabsChromeTheme.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturationToken(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
|
||||
if (element.style.getPropertyValue(property) === value) return;
|
||||
element.style.setProperty(property, value);
|
||||
};
|
||||
|
||||
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
|
||||
if (!element.style.getPropertyValue(property)) return;
|
||||
element.style.removeProperty(property);
|
||||
};
|
||||
|
||||
const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--top-tabs-bg',
|
||||
'--top-tabs-fg',
|
||||
'--top-tabs-muted',
|
||||
'--top-tabs-active-bg',
|
||||
'--top-tabs-accent',
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--primary',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
82
application/app/workTabSurface.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOrderedWorkTabIds,
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
|
||||
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
const logViewIds = new Set(['log-1']);
|
||||
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
|
||||
|
||||
for (const activeTabId of orderedTabs) {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared host tree recognizes active log view before tab ordering catches up', () => {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: 'log-1',
|
||||
logViewIds: new Set(['log-1']),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
|
||||
});
|
||||
|
||||
test('shared host tree resolves active host ids across work tab types', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1' },
|
||||
{ id: 'session-2', hostId: 'host-2' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [
|
||||
{ id: 'workspace-1', focusedSessionId: 'session-2' },
|
||||
] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
87
application/app/workTabSurface.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds = new Set(),
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!enabled) return false;
|
||||
if (isRootPageTabId(activeTabId)) return false;
|
||||
return orderedTabs.includes(activeTabId)
|
||||
|| isEditorTabId(activeTabId)
|
||||
|| logViewIds.has(activeTabId)
|
||||
|| sessionIds.has(activeTabId)
|
||||
|| workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
}): string | null {
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) return activeSession.hostId ?? null;
|
||||
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId) {
|
||||
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -174,6 +174,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
@@ -231,13 +233,33 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.hostTree.search': 'Search hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Search',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
|
||||
'terminal.layer.hostTree.newGroup': 'New group',
|
||||
'terminal.layer.hostTree.localShell': 'Local shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
|
||||
'terminal.layer.hostTree.clearTags': 'Clear selection',
|
||||
'terminal.layer.hostTree.collapse': 'Collapse host list',
|
||||
'terminal.layer.hostTree.expand': 'Expand host list',
|
||||
'terminal.layer.hostTree.empty': 'No hosts found',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'User',
|
||||
'terminal.layer.hostTree.details.port': 'Port',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocol',
|
||||
'terminal.layer.hostTree.details.group': 'Group',
|
||||
'terminal.layer.hostTree.details.tags': 'Tags',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.windowOpacity': 'Window opacity',
|
||||
'topTabs.toggleTheme': 'Toggle theme',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
|
||||
'ai.chat.terminalSelectionLines': 'lines: {count}',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
|
||||
@@ -159,6 +159,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -223,6 +225,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -262,14 +266,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
@@ -437,6 +442,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -589,6 +596,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
'vault.groups.newSubgroup': 'New Subgroup',
|
||||
'vault.groups.rename': 'Rename Group',
|
||||
'vault.groups.unnamed': 'Unnamed Group',
|
||||
'vault.groups.delete': 'Delete Group',
|
||||
'vault.groups.createSubfolder': 'Create Subfolder',
|
||||
'vault.groups.createRoot': 'Create Root Group',
|
||||
@@ -661,6 +669,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
@@ -20,6 +21,14 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
@@ -70,6 +79,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
@@ -77,6 +87,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': 'Username',
|
||||
@@ -492,6 +504,8 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
|
||||
@@ -123,6 +123,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.list': 'Bookmarked paths',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
@@ -153,6 +154,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -197,6 +200,9 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
'sftp.encoding.auto': 'Auto',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -348,6 +354,10 @@ export const enVaultMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
'settings.sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Default View Mode',
|
||||
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
|
||||
|
||||
@@ -174,6 +174,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -231,13 +233,26 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.hostTree.search': 'Поиск хостов...',
|
||||
'terminal.layer.hostTree.searchButton': 'Поиск',
|
||||
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
|
||||
'terminal.layer.hostTree.newGroup': 'Новая группа',
|
||||
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
|
||||
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
|
||||
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
|
||||
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
|
||||
'terminal.layer.hostTree.empty': 'Хосты не найдены',
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.toggleTheme': 'Переключить тему',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
|
||||
'ai.chat.terminalSelectionLines': 'строк: {count}',
|
||||
'ai.chat.collapse': 'Свернуть',
|
||||
'ai.chat.expand': 'Развернуть',
|
||||
'ai.chat.enableAgent': 'Включить {name}',
|
||||
|
||||
@@ -159,6 +159,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -223,6 +225,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
@@ -262,14 +266,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
@@ -437,6 +442,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
@@ -41,6 +42,14 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
|
||||
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
|
||||
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
|
||||
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
@@ -91,6 +100,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.addSelectionToAI': 'Добавить в чат',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
@@ -98,6 +108,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
@@ -507,6 +519,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
|
||||
@@ -158,6 +158,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Фильтр по имени файла...',
|
||||
'sftp.bookmark.add': 'Добавить путь в закладки',
|
||||
'sftp.bookmark.remove': 'Удалить закладку',
|
||||
'sftp.bookmark.list': 'Закладки путей',
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
@@ -188,6 +189,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.viewMode.switchToList': 'Переключиться на список',
|
||||
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
@@ -232,6 +235,9 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
|
||||
'sftp.goUp': 'Наверх',
|
||||
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
|
||||
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
|
||||
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
|
||||
'sftp.encoding.label': 'Кодировка имён файлов',
|
||||
'sftp.encoding.auto': 'Авто',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
@@ -383,6 +389,10 @@ export const ruVaultMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
|
||||
'settings.sftp.followTerminalCwd': 'Следовать за каталогом терминала',
|
||||
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
|
||||
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
|
||||
|
||||
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
|
||||
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',
|
||||
|
||||
@@ -174,6 +174,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
@@ -231,13 +233,33 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.hostTree.search': '搜索主机...',
|
||||
'terminal.layer.hostTree.searchButton': '搜索',
|
||||
'terminal.layer.hostTree.tagsButton': '按标签筛选',
|
||||
'terminal.layer.hostTree.newGroup': '新建分组',
|
||||
'terminal.layer.hostTree.localShell': '本地 Shell',
|
||||
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
|
||||
'terminal.layer.hostTree.clearTags': '清除筛选',
|
||||
'terminal.layer.hostTree.collapse': '收起主机列表',
|
||||
'terminal.layer.hostTree.expand': '展开主机列表',
|
||||
'terminal.layer.hostTree.empty': '没有匹配的主机',
|
||||
'terminal.layer.hostTree.details.host': '主机',
|
||||
'terminal.layer.hostTree.details.user': '用户',
|
||||
'terminal.layer.hostTree.details.port': '端口',
|
||||
'terminal.layer.hostTree.details.protocol': '协议',
|
||||
'terminal.layer.hostTree.details.group': '分组',
|
||||
'terminal.layer.hostTree.details.tags': '标签',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近连接',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.windowOpacity': '窗口透明度',
|
||||
'topTabs.toggleTheme': '切换主题',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '终端选区',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
|
||||
@@ -143,6 +143,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -207,6 +209,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -246,14 +250,15 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
@@ -365,6 +370,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.groups.hostsCount': '{count} 台主机',
|
||||
'vault.groups.newSubgroup': '新建子分组',
|
||||
'vault.groups.rename': '重命名分组',
|
||||
'vault.groups.unnamed': '未命名分组',
|
||||
'vault.groups.delete': '删除分组',
|
||||
'vault.groups.createSubfolder': '创建子分组',
|
||||
'vault.groups.createRoot': '创建根分组',
|
||||
@@ -437,6 +443,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -537,6 +544,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.list': '收藏路径',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
@@ -567,6 +575,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.viewMode.switchToList': '切换到列表视图',
|
||||
'sftp.viewMode.switchToTree': '切换到树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
@@ -611,6 +621,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
'sftp.encoding.auto': '自动',
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
@@ -79,6 +80,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.followTerminalCwd': '追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
|
||||
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
@@ -286,6 +292,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
@@ -479,6 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
|
||||
@@ -229,6 +229,14 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
|
||||
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
|
||||
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
|
||||
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
@@ -279,6 +287,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
@@ -286,6 +295,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '用户名',
|
||||
|
||||
20
application/state/activeChromeThemeSync.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
|
||||
"sync chrome theme listeners must run before deferred react notify",
|
||||
);
|
||||
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
|
||||
assert.match(syncSource, /isActiveChromeThemeResolvable/);
|
||||
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
|
||||
});
|
||||
39
application/state/activeChromeThemeSync.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
|
||||
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import type { EditorTab } from './editorTabStore';
|
||||
import type { LogView } from './logViewState';
|
||||
import { syncActiveChromeTheme } from './useActiveChromeTheme';
|
||||
|
||||
export type ActiveChromeThemeDeps = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
applyAppTheme: () => void;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
let depsRef: ActiveChromeThemeDeps | null = null;
|
||||
|
||||
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
|
||||
depsRef = deps;
|
||||
}
|
||||
|
||||
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
|
||||
if (!depsRef || typeof document === 'undefined') return;
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
|
||||
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
|
||||
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
|
||||
}
|
||||
|
||||
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);
|
||||
14
application/state/activeTabStore.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
});
|
||||
|
||||
test('editor tab helper detects editor top-tab ids', () => {
|
||||
assert.equal(isEditorTabId('editor:file-1'), true);
|
||||
assert.equal(isEditorTabId('session-1'), false);
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
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;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
@@ -18,19 +21,37 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
private syncListeners = new Set<SyncListener>();
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
private scheduleNotify = () => {
|
||||
if (this.notifyRafId !== null) return;
|
||||
const schedule = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
|
||||
this.notifyRafId = schedule(() => {
|
||||
this.notifyRafId = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
});
|
||||
};
|
||||
|
||||
setActiveTabId = (id: string) => {
|
||||
if (this.activeTabId !== id) {
|
||||
terminalLayoutSuppressStore.begin();
|
||||
this.activeTabId = id;
|
||||
// Defer listener notification to avoid "setState during render" if called from a render phase
|
||||
if (this.pendingNotify) return;
|
||||
this.pendingNotify = true;
|
||||
Promise.resolve().then(() => {
|
||||
this.pendingNotify = false;
|
||||
this.listeners.forEach(listener => listener());
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -39,6 +60,11 @@ class ActiveTabStore {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
subscribeSync = (listener: SyncListener) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const activeTabStore = new ActiveTabStore();
|
||||
@@ -47,7 +73,8 @@ export const activeTabStore = new ActiveTabStore();
|
||||
export const useActiveTabId = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
activeTabStore.getActiveTabId
|
||||
activeTabStore.getActiveTabId,
|
||||
activeTabStore.getActiveTabId,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,7 +86,7 @@ export const useSetActiveTabId = () => {
|
||||
// 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]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
// Stable snapshot functions - defined once outside components
|
||||
@@ -70,7 +97,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
|
||||
export const useIsVaultActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsVaultActive
|
||||
getIsVaultActive,
|
||||
getIsVaultActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,7 +106,8 @@ export const useIsVaultActive = () => {
|
||||
export const useIsSftpActive = () => {
|
||||
return useSyncExternalStore(
|
||||
activeTabStore.subscribe,
|
||||
getIsSftpActive
|
||||
getIsSftpActive,
|
||||
getIsSftpActive,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,17 +115,5 @@ export const useIsSftpActive = () => {
|
||||
export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const editorTopId = toEditorTabId(tabId);
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupDeleteStore {
|
||||
private targetPath: string | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getTargetPath = () => this.targetPath;
|
||||
|
||||
open = (groupPath: string) => {
|
||||
this.targetPath = groupPath;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
close = () => {
|
||||
if (!this.targetPath) return;
|
||||
this.targetPath = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
|
||||
|
||||
export const useHostTreeInlineGroupDeleteTarget = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupDeleteStore.subscribe,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
);
|
||||
};
|
||||
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineGroupEdit = {
|
||||
groupPath: string;
|
||||
initialName: string;
|
||||
isNew: boolean;
|
||||
shouldScrollIntoView?: boolean;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupEditStore {
|
||||
private edit: HostTreeInlineGroupEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineGroupEdit) => {
|
||||
this.edit = {
|
||||
...edit,
|
||||
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
|
||||
};
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
markScrollHandled = () => {
|
||||
if (!this.edit?.shouldScrollIntoView) return;
|
||||
this.edit = { ...this.edit, shouldScrollIntoView: false };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
|
||||
|
||||
export const useHostTreeInlineGroupEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupEditStore.subscribe,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
41
application/state/hostTreeInlineHostEditStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineHostEdit = {
|
||||
hostId: string;
|
||||
initialName: string;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineHostEditStore {
|
||||
private edit: HostTreeInlineHostEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineHostEdit) => {
|
||||
this.edit = edit;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
|
||||
|
||||
export const useHostTreeInlineHostEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineHostEditStore.subscribe,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
@@ -32,9 +34,15 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
syncAppearanceFromStorage: () => void;
|
||||
@@ -52,15 +60,19 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -80,15 +92,19 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -167,6 +183,9 @@ export function useSettingsIpcSync({
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsTimestampsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -185,12 +204,19 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
|
||||
const nextOpacity = clampWindowOpacity(value);
|
||||
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
|
||||
setSftpFollowTerminalCwd((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
|
||||
if (value === 'list' || value === 'tree') {
|
||||
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
|
||||
@@ -199,6 +225,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -217,14 +246,18 @@ export function useSettingsIpcSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
export const DEFAULT_WINDOW_OPACITY = 1;
|
||||
export function clampWindowOpacity(opacity: unknown): number {
|
||||
const value = Number(opacity);
|
||||
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
|
||||
return Math.min(1, Math.max(0.5, value));
|
||||
}
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
export const getSystemPreference = (): 'light' | 'dark' =>
|
||||
@@ -52,10 +58,12 @@ export const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
export const DEFAULT_SFTP_FOLLOW_TERMINAL_CWD = false;
|
||||
export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -63,6 +71,7 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
// Session Logs defaults
|
||||
export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
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 readStoredString = (key: string): string | null => {
|
||||
@@ -121,11 +130,8 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
// If immersive override is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -38,8 +41,10 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
@@ -66,17 +71,21 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -97,16 +106,20 @@ interface UseSettingsStorageSyncParams {
|
||||
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -119,19 +132,19 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
@@ -142,20 +155,20 @@ export function useSettingsStorageSync({
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -305,6 +318,12 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sessionLogsTimestampsEnabled) {
|
||||
setSessionLogsTimestampsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDebugLogsEnabled) {
|
||||
@@ -325,6 +344,12 @@ export function useSettingsStorageSync({
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpFollowTerminalCwd) {
|
||||
setSftpFollowTerminalCwd(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP default view mode from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
|
||||
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
|
||||
@@ -349,6 +374,12 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -363,6 +394,12 @@ export function useSettingsStorageSync({
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
const newValue = clampWindowOpacity(e.newValue);
|
||||
if (newValue !== s.windowOpacity) {
|
||||
setWindowOpacity(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
@@ -391,13 +428,16 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpAutoSync,
|
||||
setSftpDefaultViewMode,
|
||||
setSftpDoubleClickBehavior,
|
||||
@@ -405,6 +445,7 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
|
||||
|
||||
export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
|
||||
|
||||
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
|
||||
options?.onFileWatchSynced?.(payload);
|
||||
optionsRef.current?.onFileWatchSynced?.(payload);
|
||||
});
|
||||
|
||||
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
|
||||
options?.onFileWatchError?.(payload);
|
||||
optionsRef.current?.onFileWatchError?.(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [options]);
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
});
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
|
||||
5
application/state/terminalHostTreeAnimation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
|
||||
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
46
application/state/terminalHostTreeStore.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
|
||||
clampTerminalHostTreeWidth,
|
||||
terminalHostTreeStore,
|
||||
} = await import('./terminalHostTreeStore.ts');
|
||||
|
||||
test('closing host tree state does not mutate layout width by itself', () => {
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
terminalHostTreeStore.setLayoutWidth(240);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('opening host tree state does not jump the layout width', () => {
|
||||
storage.set('netcatty_terminal_host_tree_width_v1', '300');
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('host tree restored layout width is clamped', () => {
|
||||
assert.equal(clampTerminalHostTreeWidth(80), 160);
|
||||
assert.equal(clampTerminalHostTreeWidth(999), 360);
|
||||
assert.equal(clampTerminalHostTreeWidth(0), 160);
|
||||
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
|
||||
});
|
||||
84
application/state/terminalHostTreeStore.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
|
||||
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
|
||||
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
|
||||
|
||||
export function clampTerminalHostTreeWidth(width: number): number {
|
||||
return Math.max(
|
||||
TERMINAL_HOST_TREE_MIN_WIDTH,
|
||||
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
|
||||
);
|
||||
}
|
||||
|
||||
function readIsOpen(): boolean {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
|
||||
// Legacy key stores "collapsed"; open is the inverse.
|
||||
if (stored === 'true') return false;
|
||||
if (stored === 'false') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
class TerminalHostTreeStore {
|
||||
private isOpen = readIsOpen();
|
||||
/** Live sidebar width (0 when collapsed) for top-tab alignment. */
|
||||
private layoutWidth = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getIsOpen = () => this.isOpen;
|
||||
|
||||
getLayoutWidth = () => this.layoutWidth;
|
||||
|
||||
setIsOpen = (open: boolean) => {
|
||||
if (this.isOpen === open) return;
|
||||
this.isOpen = open;
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
|
||||
open ? 'false' : 'true',
|
||||
);
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, Math.round(width));
|
||||
if (this.layoutWidth === next) return;
|
||||
this.layoutWidth = next;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
this.setIsOpen(!this.isOpen);
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const terminalHostTreeStore = new TerminalHostTreeStore();
|
||||
|
||||
export const useTerminalHostTreeOpen = () => {
|
||||
return useSyncExternalStore(
|
||||
terminalHostTreeStore.subscribe,
|
||||
terminalHostTreeStore.getIsOpen,
|
||||
terminalHostTreeStore.getIsOpen,
|
||||
);
|
||||
};
|
||||
|
||||
export const useToggleTerminalHostTree = () => {
|
||||
return useCallback(() => terminalHostTreeStore.toggle(), []);
|
||||
};
|
||||
|
||||
export const useTerminalHostTreeLayoutWidth = () => {
|
||||
return useSyncExternalStore(
|
||||
terminalHostTreeStore.subscribe,
|
||||
terminalHostTreeStore.getLayoutWidth,
|
||||
terminalHostTreeStore.getLayoutWidth,
|
||||
);
|
||||
};
|
||||
16
application/state/terminalLayoutSuppressStore.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
});
|
||||
40
application/state/terminalLayoutSuppressStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let suppressDepth = 0;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const terminalLayoutSuppressStore = {
|
||||
getActive: () => suppressDepth > 0,
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
begin: () => {
|
||||
suppressDepth += 1;
|
||||
emit();
|
||||
},
|
||||
|
||||
end: () => {
|
||||
const wasActive = suppressDepth > 0;
|
||||
suppressDepth = Math.max(0, suppressDepth - 1);
|
||||
if (wasActive) {
|
||||
emit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function useTerminalLayoutSuppressActive(): boolean {
|
||||
return useSyncExternalStore(
|
||||
terminalLayoutSuppressStore.subscribe,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
);
|
||||
}
|
||||
52
application/state/terminalSelectionAttachment.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment creates a compact terminal log attachment", () => {
|
||||
const attachment = createTerminalSelectionAttachment("line one\nline two");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(attachment.mediaType, TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE);
|
||||
assert.match(attachment.filename, /^terminal-selection-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.log$/);
|
||||
assert.equal(attachment.terminalSelection, true);
|
||||
assert.equal(attachment.previewText, "line one");
|
||||
assert.equal(attachment.lineCount, 2);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "line one\nline two");
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment preserves utf-8 terminal output", () => {
|
||||
const attachment = createTerminalSelectionAttachment("错误: 权限不足\n路径: /tmp/测试");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "错误: 权限不足\n路径: /tmp/测试");
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments expands terminal selections into prompt text", () => {
|
||||
const attachment = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("帮我看看", [attachment]),
|
||||
`帮我看看\n\n[Terminal selection: ${attachment.filename}]\ndocker ps -a\npermission denied`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts", () => {
|
||||
const attachment = createTerminalSelectionAttachment("systemctl status nginx");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("", [attachment]),
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
101
application/state/terminalSelectionAttachment.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
const MAX_PREVIEW_CHARS = 80;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToText(base64Data: string): string {
|
||||
const binary = atob(base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function buildTimestamp(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function getPreviewText(text: string): string {
|
||||
const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
||||
return firstLine.length > MAX_PREVIEW_CHARS
|
||||
? `${firstLine.slice(0, MAX_PREVIEW_CHARS - 1)}...`
|
||||
: firstLine;
|
||||
}
|
||||
|
||||
export function createTerminalSelectionAttachment(
|
||||
selection: string,
|
||||
now: Date = new Date(),
|
||||
): UploadedFile | null {
|
||||
const text = selection.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const base64Data = bytesToBase64(new TextEncoder().encode(text));
|
||||
const filename = `terminal-selection-${buildTimestamp(now)}.log`;
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename,
|
||||
dataUrl: `data:${TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE};base64,${base64Data}`,
|
||||
base64Data,
|
||||
mediaType: TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
terminalSelection: true,
|
||||
previewText: getPreviewText(text),
|
||||
lineCount: text.split(/\r?\n/).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "base64Data" | "terminalSelection">,
|
||||
): string | null {
|
||||
if (!attachment.terminalSelection) return null;
|
||||
return base64ToText(attachment.base64Data);
|
||||
}
|
||||
|
||||
export function isTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "terminalSelection">,
|
||||
): boolean {
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
): string {
|
||||
const terminalBlocks = attachments
|
||||
.filter(isTerminalSelectionAttachment)
|
||||
.map((attachment, index) => {
|
||||
const text = decodeTerminalSelectionAttachment(attachment);
|
||||
if (!text) return null;
|
||||
const label = attachment.filename || `terminal-selection-${index + 1}.log`;
|
||||
return `\n\n[Terminal selection: ${label}]\n${text}`;
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
}
|
||||
76
application/state/themeTransition.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
THEME_TRANSITION_ATTR,
|
||||
THEME_TRANSITION_MS,
|
||||
runThemeTransition,
|
||||
} from "./themeTransition.ts";
|
||||
|
||||
function createRoot() {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
attributes,
|
||||
ownerDocument: { startViewTransition: undefined },
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
removeAttribute: (name: string) => attributes.delete(name),
|
||||
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
|
||||
const root = createRoot();
|
||||
let count = 0;
|
||||
|
||||
runThemeTransition(() => {
|
||||
count += 1;
|
||||
}, root);
|
||||
runThemeTransition(() => {
|
||||
count += 2;
|
||||
}, root);
|
||||
|
||||
assert.equal(count, 3);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
});
|
||||
|
||||
test("runThemeTransition uses view transition API when available", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let finished = false;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
callback();
|
||||
return {
|
||||
finished: Promise.resolve().then(() => {
|
||||
finished = true;
|
||||
}),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
61
application/state/themeTransition.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
|
||||
|
||||
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
|
||||
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void | Promise<void>) => {
|
||||
finished: Promise<void>;
|
||||
skipTransition: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
let cancelThemeTransitionReset: (() => void) | null = null;
|
||||
|
||||
export function runThemeTransition(
|
||||
apply: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): void {
|
||||
cancelThemeTransitionReset?.();
|
||||
|
||||
const cleanup = () => {
|
||||
root.removeAttribute(THEME_TRANSITION_ATTR);
|
||||
cancelThemeTransitionReset = null;
|
||||
};
|
||||
|
||||
const doc = root.ownerDocument as DocumentWithViewTransition | null;
|
||||
const startViewTransition = doc?.startViewTransition?.bind(doc);
|
||||
|
||||
if (startViewTransition) {
|
||||
let transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>> | null = null;
|
||||
try {
|
||||
transition = startViewTransition(() => {
|
||||
apply();
|
||||
});
|
||||
} catch {
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
cancelThemeTransitionReset = () => {
|
||||
transition?.skipTransition();
|
||||
cleanup();
|
||||
};
|
||||
void transition.finished.finally(cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
@@ -941,7 +941,7 @@ export function useAIState() {
|
||||
// ── Computed ──
|
||||
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
|
||||
|
||||
return {
|
||||
return useMemo(() => ({
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
@@ -996,5 +996,60 @@ export function useAIState() {
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
};
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
removeProvider,
|
||||
activeProviderId,
|
||||
setActiveProviderId,
|
||||
activeModelId,
|
||||
setActiveModelId,
|
||||
activeProvider,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
setDefaultAgentId,
|
||||
commandBlocklist,
|
||||
setCommandBlocklist,
|
||||
commandTimeout,
|
||||
setCommandTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
agentModelMap,
|
||||
setAgentModel,
|
||||
agentProviderMap,
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
clearSessionMessages,
|
||||
cleanupOrphanedSessions,
|
||||
]);
|
||||
}
|
||||
|
||||
49
application/state/useActiveChromeTheme.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
scheduleChromeLayoutAnimation,
|
||||
} from "./useActiveChromeTheme.ts";
|
||||
|
||||
function createRafRoot() {
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
const view = {
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
const id = nextId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame: (id: number) => {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
ownerDocument: { defaultView: view },
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
const flushFrame = () => {
|
||||
const [id, callback] = callbacks.entries().next().value ?? [];
|
||||
if (!id || !callback) return false;
|
||||
callbacks.delete(id);
|
||||
callback(0);
|
||||
return true;
|
||||
};
|
||||
|
||||
return { root, flushFrame };
|
||||
}
|
||||
|
||||
test("chrome layout animations wait until theme settle frames complete", () => {
|
||||
const { root, flushFrame } = createRafRoot();
|
||||
let ran = false;
|
||||
|
||||
const cancel = scheduleChromeLayoutAnimation(() => {
|
||||
ran = true;
|
||||
}, root);
|
||||
|
||||
while (!ran && flushFrame()) {
|
||||
// Drain scheduled animation frames.
|
||||
}
|
||||
assert.equal(ran, true);
|
||||
cancel();
|
||||
});
|
||||
258
application/state/useActiveChromeTheme.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
applyTopTabsChromeThemeVars,
|
||||
clearTopTabsChromeThemeVars,
|
||||
} from "../app/topTabsChromeTheme";
|
||||
import { runThemeTransition } from "./themeTransition";
|
||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const nextLightness = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(nextLightness * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const nextSaturation = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(nextSaturation * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const CSS_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
|
||||
function buildChromeCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === "dark";
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
|
||||
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg,
|
||||
card, fg,
|
||||
cursor, primaryFg,
|
||||
secondary, fg,
|
||||
muted, mutedFg,
|
||||
cursor, primaryFg,
|
||||
"0 70% 50%", "0 0% 100%",
|
||||
border, border, cursor,
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, index) => `--${name}: ${values[index]} !important`).join("; ");
|
||||
return [
|
||||
`:root { ${rules}; }`,
|
||||
`:root[data-active-chrome-theme] [data-agent-badge] { border-color: hsl(var(--primary) / 0.2) !important; background-color: hsl(var(--primary) / 0.1) !important; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
export function themeFingerprint(theme: TerminalTheme): string {
|
||||
return `${theme.id}\0${theme.type}\0${theme.colors.background}\0${theme.colors.foreground}\0${theme.colors.cursor}`;
|
||||
}
|
||||
|
||||
function getAppliedChromeFingerprint(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.documentElement.dataset.activeChromeTheme ?? null;
|
||||
}
|
||||
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildChromeCss(theme));
|
||||
}
|
||||
|
||||
function getChromeCss(theme: TerminalTheme): string {
|
||||
const fingerprint = themeFingerprint(theme);
|
||||
let css = cssCache.get(fingerprint);
|
||||
if (!css) {
|
||||
css = buildChromeCss(theme);
|
||||
cssCache.set(fingerprint, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
const STYLE_ID = "netcatty-active-chrome-theme";
|
||||
/** Double-rAF window used to let layout settle after a paint. */
|
||||
export const INSTANT_THEME_SWITCH_SETTLE_FRAMES = 2;
|
||||
|
||||
function getAnimationView(root: HTMLElement) {
|
||||
return root.ownerDocument?.defaultView ?? globalThis.window;
|
||||
}
|
||||
|
||||
/** Run after instant theme switch finishes suppressing CSS transitions. */
|
||||
export function scheduleAfterInstantThemeSwitch(
|
||||
callback: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): () => void {
|
||||
const view = getAnimationView(root);
|
||||
const requestFrame = view?.requestAnimationFrame?.bind(view)
|
||||
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
|
||||
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
|
||||
?? ((id: number) => { globalThis.clearTimeout(id); });
|
||||
|
||||
const frameIds: number[] = [];
|
||||
const scheduleFrames = (remaining: number) => {
|
||||
const frameId = requestFrame(() => {
|
||||
const index = frameIds.indexOf(frameId);
|
||||
if (index >= 0) frameIds.splice(index, 1);
|
||||
if (remaining <= 1) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
scheduleFrames(remaining - 1);
|
||||
});
|
||||
frameIds.push(frameId);
|
||||
};
|
||||
|
||||
scheduleFrames(INSTANT_THEME_SWITCH_SETTLE_FRAMES);
|
||||
return () => {
|
||||
for (const frameId of frameIds) cancelFrame(frameId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one frame after instant theme switch settles so layout transitions can
|
||||
* start from the pre-animation state without `transition: none` on :root.
|
||||
*/
|
||||
export function scheduleChromeLayoutAnimation(
|
||||
callback: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): () => void {
|
||||
let layoutFrameId = 0;
|
||||
const cancelSettle = scheduleAfterInstantThemeSwitch(() => {
|
||||
const view = getAnimationView(root);
|
||||
const requestFrame = view?.requestAnimationFrame?.bind(view)
|
||||
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
|
||||
layoutFrameId = requestFrame(() => callback());
|
||||
}, root);
|
||||
return () => {
|
||||
cancelSettle();
|
||||
const view = getAnimationView(root);
|
||||
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
|
||||
?? ((id: number) => { globalThis.clearTimeout(id); });
|
||||
if (layoutFrameId) cancelFrame(layoutFrameId);
|
||||
};
|
||||
}
|
||||
|
||||
function removeActiveChromeTheme() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.activeChromeTheme;
|
||||
}
|
||||
|
||||
function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
runThemeTransition(() => {
|
||||
const root = document.documentElement;
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(targetClass);
|
||||
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = getChromeCss(theme);
|
||||
root.dataset.activeChromeTheme = themeFingerprint(theme);
|
||||
netcattyBridge.get()?.setTheme?.(targetClass);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
});
|
||||
}
|
||||
|
||||
export function syncActiveChromeTheme(
|
||||
activeTheme: TerminalTheme | null,
|
||||
applyAppTheme: () => void,
|
||||
): void {
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint) return;
|
||||
|
||||
if (activeTheme) {
|
||||
applyActiveChromeTheme(activeTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTopTabsChromeThemeVars();
|
||||
runThemeTransition(() => {
|
||||
removeActiveChromeTheme();
|
||||
applyAppTheme();
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveChromeTheme({
|
||||
activeTheme,
|
||||
applyAppTheme,
|
||||
}: {
|
||||
activeTheme: TerminalTheme | null;
|
||||
applyAppTheme: () => void;
|
||||
}) {
|
||||
const applyAppThemeRef = useRef(applyAppTheme);
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(activeTheme, applyAppTheme);
|
||||
}, [activeTheme, applyAppTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
removeActiveChromeTheme();
|
||||
clearTopTabsChromeThemeVars();
|
||||
applyAppThemeRef.current();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -13,7 +13,9 @@ function getBridge(): NetcattyBridge | undefined {
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
@@ -32,10 +34,28 @@ export function useAgentDiscovery(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover on mount
|
||||
useEffect(() => {
|
||||
discover();
|
||||
}, [discover]);
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
const runDiscover = () => {
|
||||
if (!cancelled) void discover();
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(runDiscover, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [discover, enabled]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
|
||||
@@ -109,11 +109,16 @@ interface RemoteVersionCheckOptions {
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const inspectFailureToastShownRef = useRef(false);
|
||||
/** True once checkRemoteVersion has completed (success or failure). Until
|
||||
* this is set, the debounced auto-sync effect will not fire, preventing
|
||||
* an empty local vault from racing ahead and overwriting a non-empty
|
||||
@@ -513,7 +518,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
});
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
@@ -521,7 +526,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} else if (!roundTripFullySynced) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,7 +595,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
@@ -637,14 +642,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
|
||||
// Surface a degraded-sync hint once per session. Retries and
|
||||
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
|
||||
inspectFailureToastShownRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
tRef.current('sync.autoSync.inspectFailedMessage'),
|
||||
tRef.current('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
@@ -677,7 +681,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
// `t` is read through tRef so locale updates don't rebuild this
|
||||
// callback and re-fire the startup retry effect on unrelated renders.
|
||||
}, []);
|
||||
|
||||
const checkRemoteVersionRef = useRef(checkRemoteVersion);
|
||||
useEffect(() => {
|
||||
checkRemoteVersionRef.current = checkRemoteVersion;
|
||||
}, [checkRemoteVersion]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -789,7 +800,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
const notifyOnFailure = attempt === 0;
|
||||
await checkRemoteVersionRef.current(
|
||||
notifyOnFailure ? undefined : { notifyOnFailure: false },
|
||||
);
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
@@ -824,7 +838,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
|
||||
34
application/state/useComposeBarHeight.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredNumber } from './useStoredNumber';
|
||||
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 72;
|
||||
export const COMPOSE_BAR_HEIGHT_MAX = 360;
|
||||
|
||||
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
|
||||
|
||||
function clampHeight(height: number): number {
|
||||
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
|
||||
}
|
||||
|
||||
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
|
||||
export function useComposeBarHeight() {
|
||||
const [height, setHeight, persist] = useStoredNumber(
|
||||
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
|
||||
COMPOSE_BAR_HEIGHT_DEFAULT,
|
||||
HEIGHT_CLAMP,
|
||||
);
|
||||
|
||||
const setClampedHeight = useCallback(
|
||||
(next: number | ((prev: number) => number)) => {
|
||||
setHeight((prev) => {
|
||||
const raw = typeof next === 'function' ? next(prev) : next;
|
||||
return clampHeight(raw);
|
||||
});
|
||||
},
|
||||
[setHeight],
|
||||
);
|
||||
|
||||
return [height, setClampedHeight, persist] as const;
|
||||
}
|
||||
106
application/state/useComposeBarPinnedSnippets.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
interface PinnedState {
|
||||
pinnedIds: string[];
|
||||
/** True when the user has never saved pins (localStorage key absent). */
|
||||
neverSaved: boolean;
|
||||
}
|
||||
|
||||
function readPinnedState(): PinnedState {
|
||||
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
|
||||
if (stored === null) {
|
||||
return { pinnedIds: [], neverSaved: true };
|
||||
}
|
||||
return {
|
||||
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
|
||||
neverSaved: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
|
||||
if (!snippetIdKey) return null;
|
||||
const ids = snippetIdKey.split('\0').filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted snippet IDs shown on the terminal compose bar quick strip.
|
||||
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
|
||||
* On first run, `defaultSeedIds` are written once when pins were never saved.
|
||||
*/
|
||||
export function useComposeBarPinnedSnippets(
|
||||
snippetIdKey?: string,
|
||||
defaultSeedIds?: readonly string[],
|
||||
) {
|
||||
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
|
||||
const skipNextPersistRef = useRef(true);
|
||||
const needsSeedRef = useRef(neverSaved);
|
||||
|
||||
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
|
||||
setPinnedState((prev) => {
|
||||
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
|
||||
return { pinnedIds: nextIds, neverSaved: false };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextPersistRef.current) {
|
||||
skipNextPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
|
||||
}, [pinnedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSeedRef.current) return;
|
||||
|
||||
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
|
||||
if (seed.length === 0) return;
|
||||
|
||||
const applySeed = () => {
|
||||
if (!needsSeedRef.current) return;
|
||||
needsSeedRef.current = false;
|
||||
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
|
||||
};
|
||||
|
||||
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
|
||||
if (!isBuiltinSeed) {
|
||||
applySeed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Brief delay so vault snippets can load before falling back to built-ins.
|
||||
const timer = setTimeout(applySeed, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [defaultSeedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = parseSnippetIdKey(snippetIdKey);
|
||||
if (!valid) return;
|
||||
setPinnedIds((prev) => {
|
||||
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [snippetIdKey, setPinnedIds]);
|
||||
|
||||
const pin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const unpin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => prev.filter((x) => x !== id));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
|
||||
|
||||
return { pinnedIds, pin, unpin, toggle, isPinned };
|
||||
}
|
||||
@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
/**
|
||||
* Infer MIME type from file extension when the browser/Electron doesn't
|
||||
* provide one (common for .yaml, .sh, .toml, and other code/text files).
|
||||
*/
|
||||
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
// Code & Scripts — all use text/plain for maximum provider compatibility
|
||||
js: 'text/plain',
|
||||
mjs: 'text/plain',
|
||||
cjs: 'text/plain',
|
||||
jsx: 'text/plain',
|
||||
ts: 'text/plain',
|
||||
tsx: 'text/plain',
|
||||
py: 'text/plain',
|
||||
rb: 'text/plain',
|
||||
rs: 'text/plain',
|
||||
go: 'text/plain',
|
||||
java: 'text/plain',
|
||||
c: 'text/plain',
|
||||
h: 'text/plain',
|
||||
cpp: 'text/plain',
|
||||
hpp: 'text/plain',
|
||||
cs: 'text/plain',
|
||||
swift: 'text/plain',
|
||||
kt: 'text/plain',
|
||||
scala: 'text/plain',
|
||||
php: 'text/plain',
|
||||
pl: 'text/plain',
|
||||
sh: 'text/plain',
|
||||
bash: 'text/plain',
|
||||
zsh: 'text/plain',
|
||||
fish: 'text/plain',
|
||||
ps1: 'text/plain',
|
||||
bat: 'text/plain',
|
||||
cmd: 'text/plain',
|
||||
sql: 'text/plain',
|
||||
r: 'text/plain',
|
||||
lua: 'text/plain',
|
||||
dart: 'text/plain',
|
||||
// Web
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
scss: 'text/plain',
|
||||
sass: 'text/plain',
|
||||
less: 'text/plain',
|
||||
vue: 'text/plain',
|
||||
svelte: 'text/plain',
|
||||
// Config / Data
|
||||
yaml: 'text/plain',
|
||||
yml: 'text/plain',
|
||||
json: 'application/json',
|
||||
jsonc: 'application/json',
|
||||
jsonl: 'application/jsonl',
|
||||
xml: 'application/xml',
|
||||
toml: 'application/toml',
|
||||
csv: 'text/csv',
|
||||
tsv: 'text/tab-separated-values',
|
||||
ini: 'text/plain',
|
||||
cfg: 'text/plain',
|
||||
conf: 'text/plain',
|
||||
env: 'text/plain',
|
||||
// Docs
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
tex: 'text/x-tex',
|
||||
rst: 'text/x-rst',
|
||||
log: 'text/plain',
|
||||
// Other typed files
|
||||
pdf: 'application/pdf',
|
||||
dockerfile: 'text/plain',
|
||||
};
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
|
||||
return fileName.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferMediaType(fileName: string, fileType: string): string {
|
||||
if (fileType) return fileType;
|
||||
const ext = getExtension(fileName);
|
||||
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
const mediaType = inferMediaType(filename, file.type);
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
|
||||
* - Custom/unknown themes are computed lazily and cached
|
||||
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
|
||||
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the CSS rule string from a TerminalTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_VARS = [
|
||||
'background', 'foreground', 'card', 'card-foreground',
|
||||
'popover', 'popover-foreground', 'primary', 'primary-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
|
||||
'border', 'input', 'ring',
|
||||
] as const;
|
||||
|
||||
function buildImmersiveCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
|
||||
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg, // background, foreground, card, card-foreground
|
||||
card, fg, // popover, popover-foreground
|
||||
cursor, primaryFg, // primary, primary-foreground
|
||||
secondary, fg, // secondary, secondary-foreground
|
||||
muted, mutedFg, // muted, muted-foreground
|
||||
cursor, primaryFg, // accent, accent-foreground
|
||||
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
|
||||
border, border, cursor, // border, input, ring
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
|
||||
return `:root { ${rules}; }`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
|
||||
function themeFingerprint(t: TerminalTheme): string {
|
||||
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
|
||||
}
|
||||
|
||||
// Pre-compute built-in themes
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
|
||||
}
|
||||
|
||||
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
|
||||
function getImmersiveCss(theme: TerminalTheme): string {
|
||||
const fp = themeFingerprint(theme);
|
||||
let css = cssCache.get(fp);
|
||||
if (!css) {
|
||||
css = buildImmersiveCss(theme);
|
||||
cssCache.set(fp, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style tag management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE_ID = 'netcatty-immersive-override';
|
||||
|
||||
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
const root = document.documentElement;
|
||||
const targetClass = isDark ? 'dark' : 'light';
|
||||
if (!root.classList.contains(targetClass)) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(targetClass);
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
// Sync native Electron window chrome
|
||||
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
|
||||
netcattyBridge.get()?.setBackgroundColor?.(bg);
|
||||
}
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.immersiveTheme;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
}) {
|
||||
const overrideActiveRef = useRef(false);
|
||||
const appliedFpRef = useRef<string | null>(null);
|
||||
const restoreRef = useRef(restoreOriginalTheme);
|
||||
restoreRef.current = restoreOriginalTheme;
|
||||
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isTerminalTab && activeTerminalTheme) {
|
||||
const fp = themeFingerprint(activeTerminalTheme);
|
||||
if (appliedFpRef.current === fp) return;
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
document.documentElement.dataset.immersiveTheme = fp;
|
||||
}
|
||||
}, [isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'immersive-fade-overlay';
|
||||
overlay.style.backgroundColor = `hsl(${bg})`;
|
||||
document.body.appendChild(overlay);
|
||||
removeImmersiveStyle();
|
||||
restoreOriginalTheme();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeImmersiveStyle();
|
||||
appliedFpRef.current = null;
|
||||
if (overrideActiveRef.current) {
|
||||
overrideActiveRef.current = false;
|
||||
restoreRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
@@ -820,6 +821,25 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession = createCopiedTerminalSessionClone(sourceSession, {
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
delete newSession.workspaceId;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
|
||||
setActiveTabId(newSessionId);
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
@@ -838,31 +858,33 @@ export const useSessionState = () => {
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
|
||||
const orderedTabs = useMemo(() => {
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
|
||||
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}, [orphanSessions, workspaces, logViews, tabOrder]);
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
], [orphanSessions, workspaces, logViews]);
|
||||
|
||||
const reorderTabs = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' = 'before') => {
|
||||
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
}, [baseWorkTabIds, tabOrder]);
|
||||
|
||||
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
|
||||
const orderedTabs = useMemo(
|
||||
() => getOrderedWorkTabs(),
|
||||
[getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
additionalTabIds: readonly string[] = [],
|
||||
) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Get all current tab IDs (orphan sessions + workspaces + log views)
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
@@ -894,7 +916,7 @@ export const useSessionState = () => {
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews]);
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -939,6 +961,7 @@ export const useSessionState = () => {
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
// Log views
|
||||
logViews,
|
||||
@@ -946,5 +969,6 @@ export const useSessionState = () => {
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition } from './themeTransition';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
@@ -25,21 +27,25 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -69,7 +75,9 @@ import {
|
||||
DEFAULT_LIGHT_UI_THEME,
|
||||
DEFAULT_SESSION_LOGS_ENABLED,
|
||||
DEFAULT_SESSION_LOGS_FORMAT,
|
||||
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
DEFAULT_SFTP_AUTO_SYNC,
|
||||
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
|
||||
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -78,9 +86,12 @@ import {
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
@@ -97,6 +108,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -202,6 +214,10 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
const [sftpFollowTerminalCwd, setSftpFollowTerminalCwd] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_FOLLOW_TERMINAL_CWD;
|
||||
});
|
||||
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
|
||||
@@ -218,6 +234,10 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -242,6 +262,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
const [sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED;
|
||||
});
|
||||
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
|
||||
@@ -272,6 +296,19 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
|
||||
if (stored === null) return DEFAULT_WINDOW_OPACITY;
|
||||
return clampWindowOpacity(stored);
|
||||
});
|
||||
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
|
||||
setWindowOpacityState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(prev)
|
||||
: nextValue;
|
||||
return clampWindowOpacity(candidate);
|
||||
});
|
||||
}, []);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -413,7 +450,9 @@ export const useSettingsState = () => {
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
runThemeTransition(() => {
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
});
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -483,6 +522,10 @@ export const useSettingsState = () => {
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
if (storedFollowTerminalCwd === 'true' || storedFollowTerminalCwd === 'false') {
|
||||
setSftpFollowTerminalCwd(storedFollowTerminalCwd === 'true');
|
||||
}
|
||||
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
@@ -491,6 +534,8 @@ export const useSettingsState = () => {
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -502,7 +547,12 @@ export const useSettingsState = () => {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
if (persistMountedRef.current) {
|
||||
runThemeTransition(apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -564,15 +614,19 @@ export const useSettingsState = () => {
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
@@ -598,19 +652,19 @@ export const useSettingsState = () => {
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -715,16 +769,16 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
|
||||
setShowHostTreeSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'netcatty-custom-css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
applyCustomCssToDocument(customCSS);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -766,6 +820,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP follow terminal cwd setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd);
|
||||
}, [sftpFollowTerminalCwd, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP default view mode
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
@@ -792,6 +853,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled);
|
||||
}, [sessionLogsTimestampsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -802,6 +869,7 @@ export const useSettingsState = () => {
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -874,8 +942,7 @@ export const useSettingsState = () => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
|
||||
const reapplyCurrentTheme = useCallback(() => {
|
||||
const applyAppTheme = useCallback(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
@@ -935,6 +1002,8 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
@@ -943,6 +1012,8 @@ export const useSettingsState = () => {
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -959,6 +1030,8 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
@@ -971,8 +1044,10 @@ export const useSettingsState = () => {
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
@@ -982,9 +1057,9 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle, sshDebugLogsEnabled,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -170,10 +170,10 @@ export const useTerminalBackend = () => {
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string) => {
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
}, []);
|
||||
|
||||
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
@@ -20,28 +20,40 @@ export const useTreeExpandedState = (storageKey: string) => {
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
const togglePath = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
const expandAll = useCallback((allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const collapseAll = () => {
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensurePathExpanded = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
if (current.has(path)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
ensurePathExpanded,
|
||||
};
|
||||
};
|
||||
@@ -109,6 +109,29 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
* scrollback for a session; with up to 500 logs it grew the
|
||||
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
|
||||
* add/update re-serialized + wrote the whole thing synchronously
|
||||
* (50–73 ms on the main thread), causing freezes on connect/disconnect.
|
||||
*
|
||||
* The full `terminalData` stays in the in-memory React state (so in-session
|
||||
* replay still works); only explicitly *saved* logs keep it on disk. This
|
||||
* keeps the persisted blob small and writes fast.
|
||||
*/
|
||||
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -318,7 +341,7 @@ export const useVaultState = () => {
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
|
||||
return final;
|
||||
});
|
||||
return newLog.id;
|
||||
@@ -332,7 +355,7 @@ export const useVaultState = () => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
@@ -360,7 +383,7 @@ export const useVaultState = () => {
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
|
||||
return saved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
50
application/state/vaultHostTreeActionsStore.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host } from '../../types';
|
||||
|
||||
export interface VaultHostTreeActions {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onRenameHost: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class VaultHostTreeActionsStore {
|
||||
private actions: VaultHostTreeActions | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getActions = () => this.actions;
|
||||
|
||||
setActions = (actions: VaultHostTreeActions | null) => {
|
||||
this.actions = actions;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const vaultHostTreeActionsStore = new VaultHostTreeActionsStore();
|
||||
|
||||
export const useVaultHostTreeActions = () => {
|
||||
return useSyncExternalStore(
|
||||
vaultHostTreeActionsStore.subscribe,
|
||||
vaultHostTreeActionsStore.getActions,
|
||||
vaultHostTreeActionsStore.getActions,
|
||||
);
|
||||
};
|
||||
@@ -143,6 +143,14 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
||||
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
||||
@@ -228,6 +236,24 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
showHostTreeSidebar: false,
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
// Without this nudge, the apply path writes to localStorage but
|
||||
// `useAIState` (listening for `storage` events) never sees the changes
|
||||
|
||||
@@ -56,12 +56,14 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -190,7 +192,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
@@ -220,6 +222,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -386,6 +389,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
|
||||
if (followTerminalCwd === 'true' || followTerminalCwd === 'false') settings.sftpFollowTerminalCwd = followTerminalCwd === 'true';
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
@@ -400,6 +405,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
@@ -512,6 +519,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
|
||||
if (settings.sftpDefaultViewMode != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
|
||||
}
|
||||
@@ -519,7 +527,6 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
// SFTP Bookmarks (global only)
|
||||
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
||||
|
||||
// Immersive mode (legacy — always enabled, ignore incoming value)
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
@@ -530,6 +537,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -9,6 +9,11 @@ import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
type ExportFormat = 'md' | 'json' | 'txt';
|
||||
@@ -111,138 +116,163 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode
|
||||
}) => (
|
||||
}) => {
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
|
||||
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
|
||||
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
|
||||
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
|
||||
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
{showHistory && !hideHistory ? (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
{!hideMessages && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</React.Profiler>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -19,6 +19,9 @@ interface SessionHistoryDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SESSION_RENDER_BATCH = 80;
|
||||
const SESSION_RENDER_STEP = 60;
|
||||
|
||||
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
@@ -27,6 +30,15 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderCount(SESSION_RENDER_BATCH);
|
||||
}, [sessions]);
|
||||
|
||||
const displayedSessions = sessions.slice(0, renderCount);
|
||||
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
|
||||
@@ -47,7 +59,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
<>
|
||||
{hiddenSessionCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderCount((count) => count + SESSION_RENDER_STEP)}
|
||||
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.loadMoreSessions').replace('{n}', String(hiddenSessionCount))}
|
||||
</button>
|
||||
)}
|
||||
{displayedSessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const time = new Date(session.updatedAt);
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
@@ -85,7 +107,8 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
102
components/AIChatSidePanel.mountRetention.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
||||
import {
|
||||
hasAIChatSidePanelRetainedContent,
|
||||
shouldKeepAIChatSidePanelMounted,
|
||||
} from './AIChatSidePanel.tsx';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
|
||||
|
||||
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const session = (overrides: Partial<AISession> = {}): AISession => ({
|
||||
id: 'session-1',
|
||||
title: 'Session',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
|
||||
sessions: [],
|
||||
activeSessionIdMap: {},
|
||||
draftsByScope: {},
|
||||
panelViewByScope: {},
|
||||
setActiveSessionId: () => undefined,
|
||||
ensureDraftForScope: () => undefined,
|
||||
updateDraft: () => undefined,
|
||||
showDraftView: () => undefined,
|
||||
showSessionView: () => undefined,
|
||||
clearDraftForScope: () => undefined,
|
||||
addDraftFiles: async () => undefined,
|
||||
removeDraftFile: () => undefined,
|
||||
createSession: () => session(),
|
||||
deleteSession: () => undefined,
|
||||
updateSessionTitle: () => undefined,
|
||||
updateSessionExternalSessionId: () => undefined,
|
||||
addMessageToSession: () => undefined,
|
||||
updateLastMessage: () => undefined,
|
||||
updateMessageById: () => undefined,
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
activeModelId: '',
|
||||
defaultAgentId: 'catty',
|
||||
toolIntegrationMode: 'mcp',
|
||||
externalAgents: [],
|
||||
agentModelMap: {},
|
||||
setAgentModel: () => undefined,
|
||||
agentProviderMap: {},
|
||||
setAgentProvider: () => undefined,
|
||||
globalPermissionMode: 'autonomous',
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
isVisible: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('hidden empty AI side panel can release its subtree', () => {
|
||||
const props = baseProps();
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has draft text', () => {
|
||||
const props = baseProps({
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'hello' }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has session messages', () => {
|
||||
const props = baseProps({
|
||||
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
||||
sessions: [
|
||||
session({
|
||||
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('visible AI side panel is always mounted even when empty', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import type {
|
||||
@@ -29,14 +29,19 @@ import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
isAIChatSessionStreaming,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
@@ -44,8 +49,47 @@ import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import {
|
||||
getAIPanelProfilerProps,
|
||||
profileAIPanelCalculation,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
export function hasAIChatSidePanelRetainedContent(props: Pick<
|
||||
AIChatSidePanelProps,
|
||||
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
|
||||
>): boolean {
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
const activeSession = sessionId
|
||||
? props.sessions.find((session) => session.id === sessionId)
|
||||
: null;
|
||||
if (activeSession && activeSession.messages.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const draft = props.draftsByScope[scopeKey] ?? null;
|
||||
return Boolean(
|
||||
draft
|
||||
&& (
|
||||
draft.text.trim().length > 0
|
||||
|| draft.attachments.length > 0
|
||||
|| draft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
|
||||
if (props.isVisible ?? true) {
|
||||
return true;
|
||||
}
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
if (hasAIChatSidePanelRetainedContent(props)) {
|
||||
return true;
|
||||
}
|
||||
return isAIChatSessionStreaming(sessionId);
|
||||
}
|
||||
|
||||
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
@@ -130,23 +174,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const deferredSessions = useDeferredValue(sessions);
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
() => profileAIPanelCalculation(
|
||||
'AIChatSidePanel.historySessions',
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
),
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
@@ -197,16 +237,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiMcpUpdateSessions) {
|
||||
if (!bridge?.aiMcpUpdateSessions) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isVisible, terminalSessions, activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
@@ -338,30 +386,27 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncProviders && providers.length > 0) {
|
||||
void bridge.aiSyncProviders(providers);
|
||||
}
|
||||
}, [providers]);
|
||||
}, [isVisible, providers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const bridge = getNetcattyBridge();
|
||||
if (bridge?.aiSyncWebSearch) {
|
||||
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
rediscover,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
|
||||
|
||||
const handleEnableDiscoveredAgent = useCallback(
|
||||
(agent: DiscoveredAgent) => {
|
||||
@@ -452,6 +497,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
@@ -474,12 +520,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isCodexManagedAgent, currentAgentId]);
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
@@ -522,7 +569,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
@@ -650,18 +697,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
if (!trimmed || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
@@ -691,7 +744,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -701,7 +758,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -741,7 +802,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
@@ -765,7 +826,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
@@ -778,7 +839,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
titleText: trimmed,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
@@ -847,60 +909,130 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
handleAgentChange={handleAgentChange}
|
||||
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
rediscover={rediscover}
|
||||
handleOpenSettings={handleOpenSettings}
|
||||
activeSession={activeSession}
|
||||
handleExport={handleExport}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
handleNewChat={handleNewChat}
|
||||
historySessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleStop={handleStop}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
handleAgentChange={handleAgentChange}
|
||||
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
rediscover={rediscover}
|
||||
handleOpenSettings={handleOpenSettings}
|
||||
activeSession={activeSession}
|
||||
handleExport={handleExport}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
handleNewChat={handleNewChat}
|
||||
historySessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleStop={handleStop}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
</React.Profiler>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
|
||||
const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'sessions',
|
||||
'activeSessionIdMap',
|
||||
'draftsByScope',
|
||||
'panelViewByScope',
|
||||
'setActiveSessionId',
|
||||
'ensureDraftForScope',
|
||||
'updateDraft',
|
||||
'showDraftView',
|
||||
'showSessionView',
|
||||
'clearDraftForScope',
|
||||
'addDraftFiles',
|
||||
'removeDraftFile',
|
||||
'createSession',
|
||||
'deleteSession',
|
||||
'updateSessionTitle',
|
||||
'updateSessionExternalSessionId',
|
||||
'addMessageToSession',
|
||||
'updateLastMessage',
|
||||
'updateMessageById',
|
||||
'providers',
|
||||
'activeProviderId',
|
||||
'activeModelId',
|
||||
'defaultAgentId',
|
||||
'toolIntegrationMode',
|
||||
'externalAgents',
|
||||
'setExternalAgents',
|
||||
'agentModelMap',
|
||||
'setAgentModel',
|
||||
'agentProviderMap',
|
||||
'setAgentProvider',
|
||||
'globalPermissionMode',
|
||||
'setGlobalPermissionMode',
|
||||
'commandBlocklist',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
prev: AIChatSidePanelProps,
|
||||
next: AIChatSidePanelProps,
|
||||
): boolean {
|
||||
const prevKeep = shouldKeepAIChatSidePanelMounted(prev);
|
||||
const nextKeep = shouldKeepAIChatSidePanelMounted(next);
|
||||
if (!prevKeep && !nextKeep) {
|
||||
return true;
|
||||
}
|
||||
if (prevKeep !== nextKeep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.scopeType !== next.scopeType) return false;
|
||||
if (prev.scopeTargetId !== next.scopeTargetId) return false;
|
||||
if (prev.scopeLabel !== next.scopeLabel) return false;
|
||||
if (prev.scopeHostIds !== next.scopeHostIds) return false;
|
||||
if (prev.terminalSessions !== next.terminalSessions) return false;
|
||||
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
|
||||
|
||||
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
|
||||
if (prev[key] !== next[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
|
||||
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
|
||||
// Keep hidden panels alive only when they contain real work (messages, draft
|
||||
// content, or an active stream). Empty hidden panels can drop their heavy
|
||||
// input/agent-picker subtree and remount cheaply when shown again.
|
||||
return <AIChatSidePanelActive {...props} />;
|
||||
}, aiChatSidePanelPropsAreEqual);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
export default AIChatSidePanel;
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
Bookmark,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -66,6 +67,7 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,8 +84,8 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* User column */}
|
||||
<div className="flex items-center gap-2 w-56 shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<User size={14} />
|
||||
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
|
||||
<CircleUserRound size={18} strokeWidth={2.25} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{log.localUsername}</div>
|
||||
@@ -93,12 +95,28 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* Host column */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
{hasPersistedHostIcon ? (
|
||||
<DistroAvatar
|
||||
host={{
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
|
||||
isSerial
|
||||
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
|
||||
: isLocal
|
||||
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
|
||||
@@ -68,10 +68,12 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Host;
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
size?: "xs" | "sm" | "md" | "tree" | "log" | "lg";
|
||||
};
|
||||
|
||||
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
@@ -85,16 +87,22 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const [errored, setErrored] = React.useState(false);
|
||||
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
xs: "h-4 w-4 rounded",
|
||||
sm: "h-5 w-5 rounded",
|
||||
md: "h-8 w-8 rounded",
|
||||
tree: "h-8 w-8 rounded-lg",
|
||||
log: "h-9 w-9 rounded-xl",
|
||||
lg: "h-11 w-11 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-5 w-5",
|
||||
lg: "h-6 w-6",
|
||||
xs: "h-2.5 w-2.5",
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
tree: "h-4 w-4",
|
||||
log: "h-5 w-5",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
const containerClass = sizeClasses[size];
|
||||
@@ -105,8 +113,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -119,8 +127,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center overflow-hidden",
|
||||
containerClass,
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
@@ -138,8 +146,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-primary/15 text-primary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* FileOpenerDialog - Dialog for choosing how to open a file
|
||||
*/
|
||||
import { Edit2, FolderOpen } from 'lucide-react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { getFileExtension, hasFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
@@ -26,13 +26,17 @@ const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isSelectingApp, setIsSelectingApp] = useState(false);
|
||||
const [rememberChoice, setRememberChoice] = useState(true);
|
||||
const [rememberChoice, setRememberChoice] = useState(() => hasFileExtension(fileName));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRememberChoice(hasFileExtension(fileName));
|
||||
}
|
||||
}, [open, fileName]);
|
||||
|
||||
const extension = getFileExtension(fileName);
|
||||
// Show edit option for files that are not known binary formats
|
||||
const canEdit = !isKnownBinaryFile(fileName);
|
||||
// For files without extension, we use 'file' as virtual extension
|
||||
// So we always allow setting default (hasExtension is always true)
|
||||
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
|
||||
|
||||
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
|
||||
|
||||
@@ -45,7 +45,8 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => (
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
@@ -732,4 +733,5 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
hostTreeInlineGroupEditStore,
|
||||
useHostTreeInlineGroupEdit,
|
||||
} from '../application/state/hostTreeInlineGroupEditStore';
|
||||
import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActionsStore';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
@@ -8,7 +13,9 @@ import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
|
||||
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
|
||||
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
@@ -26,12 +33,14 @@ interface HostTreeViewProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -54,12 +63,14 @@ interface TreeNodeProps {
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => void;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
@@ -83,14 +94,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
commitInlineGroupRename,
|
||||
cancelInlineGroupEdit,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
@@ -99,8 +112,22 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
setDragOverDropTarget,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
const commitRename = commitInlineGroupRename ?? vaultTreeActions?.commitInlineGroupRename;
|
||||
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
|
||||
const isInlineEditing = inlineEdit?.groupPath === node.path;
|
||||
const groupRowRef = useRef<HTMLDivElement>(null);
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInlineEditing || !inlineEdit?.shouldScrollIntoView) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
groupRowRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
hostTreeInlineGroupEditStore.markScrollHandled();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
@@ -144,18 +171,31 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (isInlineEditing) return;
|
||||
onToggle(node.path);
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
ref={groupRowRef}
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="group"
|
||||
data-group-path={node.path}
|
||||
draggable={!isInlineEditing}
|
||||
onDragStart={(e) => {
|
||||
if (isInlineEditing) return;
|
||||
e.dataTransfer.setData("group-path", node.path);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -185,10 +225,23 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={21} strokeWidth={2.35} />
|
||||
) : (
|
||||
<Folder size={21} strokeWidth={2.35} />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{isInlineEditing && commitRename && cancelRename ? (
|
||||
<HostTreeGroupInlineRenameInput
|
||||
initialName={inlineEdit.initialName}
|
||||
onCommit={commitRename}
|
||||
onCancel={cancelRename}
|
||||
className="flex-1 font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
)}
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
<FileSymlink size={10} />
|
||||
@@ -212,28 +265,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
{isManaged && onUnmanageGroup && (
|
||||
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
|
||||
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
<HostTreeGroupContextMenuContent
|
||||
groupPath={node.path}
|
||||
isManaged={isManaged}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
@@ -251,14 +290,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
commitInlineGroupRename={commitInlineGroupRename}
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
@@ -344,7 +385,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
toggleHostSelection,
|
||||
groupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
@@ -366,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="host"
|
||||
data-host-id={host.id}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => {
|
||||
@@ -390,7 +433,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate flex items-center gap-1.5">
|
||||
@@ -425,26 +468,13 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -462,14 +492,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onRenameGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
commitInlineGroupRename,
|
||||
cancelInlineGroupEdit,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
@@ -479,6 +511,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupConfigs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
|
||||
|
||||
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!inlineEdit?.groupPath || !cancelRename) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('[data-inline-group-edit="true"]')) return;
|
||||
const row = target.closest('[data-section="host-tree-row"]');
|
||||
if (!row) return;
|
||||
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
|
||||
cancelRename();
|
||||
}, [cancelRename, inlineEdit?.groupPath]);
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
@@ -550,7 +596,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
@@ -589,14 +635,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onRenameGroup={onRenameGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
commitInlineGroupRename={commitInlineGroupRename}
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
@@ -678,7 +679,7 @@ echo $3 >> "$FILE"`);
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("keychain.section.keys")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -743,7 +744,7 @@ echo $3 >> "$FILE"`);
|
||||
{activeFilter === "key" && filteredIdentities.length > 0 && (
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("keychain.section.identities")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
@@ -167,9 +168,10 @@ const HostItem = React.memo<HostItemProps>(
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
@@ -205,9 +207,10 @@ const HostItem = React.memo<HostItemProps>(
|
||||
converted && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
|
||||
@@ -247,34 +247,34 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
|
||||
isLocal
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-blue-500/10 text-blue-500"
|
||||
)}
|
||||
>
|
||||
<FileText size={16} />
|
||||
<FileText size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<div className="min-w-0 text-sm font-medium leading-none truncate">
|
||||
{isLocal ? t("logs.localTerminal") : log.hostname}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs leading-none text-muted-foreground truncate">
|
||||
{formattedDate} • {log.localUsername}@{log.localHostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 shrink-0 items-center gap-1.5">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
@@ -287,18 +287,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
|
||||
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
|
||||
{t("logView.readOnly")}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X size={16} />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
@@ -690,9 +691,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!hasRules ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<div className="flex h-full flex-col items-center justify-center p-3 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Zap size={32} className="opacity-60" />
|
||||
</div>
|
||||
@@ -704,9 +705,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">{t("pf.title")}</h2>
|
||||
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("pf.rulesCount", { count: filteredRules.length })}
|
||||
</span>
|
||||
|
||||
@@ -59,7 +59,14 @@ import {
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import {
|
||||
VaultEntityIcon,
|
||||
vaultProxyCommandIconClass,
|
||||
vaultProxyHttpIconClass,
|
||||
vaultProxySocksIconClass,
|
||||
} from "./vault/VaultEntityIcon";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
@@ -99,17 +106,17 @@ const proxyProtocolMeta = {
|
||||
http: {
|
||||
label: "HTTP",
|
||||
Icon: Globe,
|
||||
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
iconClassName: vaultProxyHttpIconClass,
|
||||
},
|
||||
socks5: {
|
||||
label: "SOCKS5",
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
iconClassName: vaultProxySocksIconClass,
|
||||
},
|
||||
command: {
|
||||
labelKey: "hostDetails.proxyPanel.command",
|
||||
Icon: SquareTerminal,
|
||||
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
iconClassName: vaultProxyCommandIconClass,
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label?: string;
|
||||
@@ -163,15 +170,11 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={protocol.iconClassName}
|
||||
title={protocolLabel}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
icon={<ProtocolIcon size={18} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
@@ -397,7 +400,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("proxyProfiles.section.proxies")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -46,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [noAutoRun, setNoAutoRun] = useState(false);
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -58,6 +59,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
setNoAutoRun(false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
@@ -75,6 +77,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setNoAutoRun(snippet.noAutoRun ?? false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -121,6 +124,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
@@ -130,10 +134,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -199,6 +204,16 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noAutoRun}
|
||||
onChange={(e) => setNoAutoRun(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
};
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
@@ -69,6 +76,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
if (!isVisible) return new Set<string>();
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
@@ -87,7 +95,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
}, [packages, snippets, isVisible]);
|
||||
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
@@ -99,6 +107,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
@@ -110,10 +119,53 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
// Only auto-expand root packages on first sight — expanding the full
|
||||
// tree upfront was freezing the panel on large snippet libraries.
|
||||
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
}, [normalizedPackages, isVisible]);
|
||||
|
||||
const snippetIndex = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return {
|
||||
snippetsByPackage: new Map<string, Snippet[]>(),
|
||||
descendantCountByPackage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
|
||||
const bumpCount = (path: string) => {
|
||||
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const snippet of snippets) {
|
||||
const pkg = snippet.package || '';
|
||||
const bucket = snippetsByPackage.get(pkg);
|
||||
if (bucket) bucket.push(snippet);
|
||||
else snippetsByPackage.set(pkg, [snippet]);
|
||||
|
||||
if (pkg === '') {
|
||||
bumpCount('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = pkg;
|
||||
while (true) {
|
||||
bumpCount(path);
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash < 0) break;
|
||||
path = path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of snippetsByPackage.values()) {
|
||||
bucket.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return { snippetsByPackage, descendantCountByPackage };
|
||||
}, [snippets, isVisible]);
|
||||
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
@@ -126,6 +178,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!isVisible) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
@@ -133,9 +186,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
}, [snippets, search, isVisible]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
@@ -159,15 +213,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
};
|
||||
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
@@ -181,7 +227,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
@@ -200,7 +246,38 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
|
||||
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
|
||||
|
||||
const listItems = useMemo((): ScriptsListItem[] => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) {
|
||||
return searchMatches.map((snippet) => ({
|
||||
key: `search:${snippet.id}`,
|
||||
kind: 'search',
|
||||
snippet,
|
||||
}));
|
||||
}
|
||||
return rows.flatMap((row): ScriptsListItem[] => {
|
||||
if (row.type === 'package') {
|
||||
return [{
|
||||
key: `pkg:${row.id}`,
|
||||
kind: 'package',
|
||||
row,
|
||||
countLabel: t('snippets.package.count', { count: row.count }),
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
key: `snip:${row.id}`,
|
||||
kind: 'snippet',
|
||||
row,
|
||||
}];
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
@@ -265,62 +342,62 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
{!hasAnyContent && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0">
|
||||
{!hasAnyContent ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
contentClassName="py-1"
|
||||
items={listItems}
|
||||
itemHeight={SCRIPT_ROW_HEIGHT}
|
||||
getItemKey={(item) => item.key}
|
||||
renderItem={(item) => {
|
||||
if (item.kind === 'search') {
|
||||
return (
|
||||
<SnippetRow
|
||||
snippet={item.snippet}
|
||||
depth={0}
|
||||
subtitle={item.snippet.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(item.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'package') {
|
||||
return (
|
||||
<PackageRow
|
||||
row={item.row}
|
||||
countLabel={item.countLabel}
|
||||
onToggle={() => togglePackage(item.row.path)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
snippet={item.row.snippet}
|
||||
depth={item.row.depth}
|
||||
onClick={() => handleSnippetClick(item.row.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -332,7 +409,7 @@ interface PackageRowProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@@ -351,7 +428,8 @@ const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) =>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
));
|
||||
PackageRow.displayName = 'PackageRow';
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
@@ -364,7 +442,7 @@ interface SnippetRowProps {
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
const SnippetRow = memo<SnippetRowProps>(({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
@@ -415,7 +493,8 @@ const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -343,7 +343,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-8 w-8 rounded-md"
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tooltip>
|
||||
|
||||
@@ -16,28 +16,49 @@ type AppInfo = {
|
||||
};
|
||||
|
||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
||||
const BUG_REPORT_TEMPLATE = "bug_report.yml";
|
||||
|
||||
const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const title = "Bug: ";
|
||||
const bodyLines = [
|
||||
"## Describe the problem",
|
||||
"",
|
||||
"## Steps to reproduce",
|
||||
"1.",
|
||||
"",
|
||||
"## Expected behavior",
|
||||
"",
|
||||
"## Actual behavior",
|
||||
"",
|
||||
"## Environment",
|
||||
`- App: ${appInfo.name} ${appInfo.version}`,
|
||||
`- Platform: ${appInfo.platform || "unknown"}`,
|
||||
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
|
||||
];
|
||||
const mapIssuePlatform = (platform?: string) => {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
|
||||
export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
template: BUG_REPORT_TEMPLATE,
|
||||
title: "[Bug] ",
|
||||
});
|
||||
|
||||
if (appInfo.version) {
|
||||
params.set("version", appInfo.version);
|
||||
}
|
||||
|
||||
const platform = mapIssuePlatform(appInfo.platform);
|
||||
if (platform) {
|
||||
params.set("platform", platform);
|
||||
}
|
||||
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
params.set(
|
||||
"logs",
|
||||
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
|
||||
);
|
||||
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsAITab from "./settings/tabs/SettingsAITab";
|
||||
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
const settingsTabIconClassName = "shrink-0";
|
||||
const settingsTabLabelClassName = "min-w-0 truncate";
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
type TerminalTabSettingsProps = Pick<
|
||||
SettingsState,
|
||||
| 'terminalThemeId'
|
||||
| 'setTerminalThemeId'
|
||||
| 'followAppTerminalTheme'
|
||||
| 'setFollowAppTerminalTheme'
|
||||
| 'terminalThemeDarkId'
|
||||
| 'setTerminalThemeDarkId'
|
||||
| 'terminalThemeLightId'
|
||||
| 'setTerminalThemeLightId'
|
||||
| 'lightUiThemeId'
|
||||
| 'darkUiThemeId'
|
||||
| 'terminalFontFamilyId'
|
||||
| 'setTerminalFontFamilyId'
|
||||
| 'terminalFontSize'
|
||||
| 'setTerminalFontSize'
|
||||
| 'terminalSettings'
|
||||
| 'updateTerminalSetting'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
terminalThemeId={terminalThemeId}
|
||||
setTerminalThemeId={setTerminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={setTerminalThemeDarkId}
|
||||
terminalThemeLightId={terminalThemeLightId}
|
||||
setTerminalThemeLightId={setTerminalThemeLightId}
|
||||
lightUiThemeId={lightUiThemeId}
|
||||
darkUiThemeId={darkUiThemeId}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
setTerminalFontSize={setTerminalFontSize}
|
||||
terminalSettings={terminalSettings}
|
||||
updateTerminalSetting={updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -325,11 +363,34 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
/>
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
<SettingsTerminalTabContainer
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -353,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && (
|
||||
@@ -366,6 +425,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={settings.sessionLogsTimestampsEnabled}
|
||||
setSessionLogsTimestampsEnabled={settings.setSessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
|
||||
@@ -108,15 +108,13 @@ test("clipboard files become path-backed upload entries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("clipboard upload ignores directories until recursive paste is supported", () => {
|
||||
test("clipboard upload keeps directories for recursive folder paste", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
|
||||
];
|
||||
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
]);
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
|
||||
});
|
||||
|
||||
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
|
||||
|
||||