Compare commits
292 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6ca3f477 | ||
|
|
1c9c4fcec3 | ||
|
|
8f68e24057 | ||
|
|
2374f67ffc | ||
|
|
fea8e8b305 | ||
|
|
79a7e460be | ||
|
|
f48db8ee4e | ||
|
|
ba2a0389fa | ||
|
|
6309a49c37 | ||
|
|
b1291d3ee2 | ||
|
|
18c001e9c5 | ||
|
|
c2c6b265d4 | ||
|
|
1e50b66407 | ||
|
|
2fb2155d79 | ||
|
|
3429c498f9 | ||
|
|
dc7b14e323 | ||
|
|
5d675b9cef | ||
|
|
bf9f0e1fc2 | ||
|
|
02967d9258 | ||
|
|
343176120e | ||
|
|
c0b4dace87 | ||
|
|
b6e8d63fef | ||
|
|
60c07da140 | ||
|
|
f89afc0e05 | ||
|
|
ca0b1ed9ae | ||
|
|
555438a02a | ||
|
|
97e78624bb | ||
|
|
eab1e8db67 | ||
|
|
8e6392e503 | ||
|
|
8b99f2411f | ||
|
|
98905b9c81 | ||
|
|
b7e1df9916 | ||
|
|
3089cab88d | ||
|
|
98dda8a51b | ||
|
|
42baa5cb78 | ||
|
|
11fd7fcd71 | ||
|
|
d6950948fa | ||
|
|
9693793bba | ||
|
|
a72f012851 | ||
|
|
1368709f4e | ||
|
|
d1408b8050 | ||
|
|
9ca68561b3 | ||
|
|
c3c579b8a0 | ||
|
|
2784ecdf28 | ||
|
|
75bbd1f300 | ||
|
|
4ee4ef7b60 | ||
|
|
32f4aadab2 | ||
|
|
fc32b44d8e | ||
|
|
76cd1f2883 | ||
|
|
76d37d982a | ||
|
|
6d2f3f28c0 | ||
|
|
a1c9f5fbd0 | ||
|
|
ce5cb2afec | ||
|
|
c771979178 | ||
|
|
58c651500e | ||
|
|
bcf653dd2e | ||
|
|
0caf19af7e | ||
|
|
e8b9122270 | ||
|
|
60071424d0 | ||
|
|
51abe7da63 | ||
|
|
9667c03ddc | ||
|
|
9935eb2ed1 | ||
|
|
268b698a39 | ||
|
|
2491d1a177 | ||
|
|
2bf2220d0b | ||
|
|
683756324e | ||
|
|
80fbf0da2f | ||
|
|
556a14178c | ||
|
|
7e566efe9c | ||
|
|
1d2489b02c | ||
|
|
5ad3d0ce32 | ||
|
|
edf013164b | ||
|
|
504b576e1c | ||
|
|
890abd1c4c | ||
|
|
0827dd416f | ||
|
|
24df4b6548 | ||
|
|
7db4b18cce | ||
|
|
844c55e99d | ||
|
|
778b43ceff | ||
|
|
6b2e5041d2 | ||
|
|
1464cba6da | ||
|
|
d74d9e28a0 | ||
|
|
32b74f4fea | ||
|
|
f284fb0505 | ||
|
|
1769edb881 | ||
|
|
a7873672c5 | ||
|
|
d2fe0ecefe | ||
|
|
3261e481ee | ||
|
|
3dfc84918b | ||
|
|
3dc9581be6 | ||
|
|
4e7d69c9ff | ||
|
|
7649243021 | ||
|
|
b770dbe6f5 | ||
|
|
1e0979e441 | ||
|
|
9dbd2a5cf7 | ||
|
|
702700d93c | ||
|
|
0413e02bf0 | ||
|
|
1cccbfe5fb | ||
|
|
1c5960a054 | ||
|
|
2ae1219bb7 | ||
|
|
591b2ba010 | ||
|
|
e26f1350f5 | ||
|
|
d36fc2db1b | ||
|
|
32ebc01552 | ||
|
|
6f93a741ff | ||
|
|
d77b0531f6 | ||
|
|
0bc45417c7 | ||
|
|
fd88b3a36b | ||
|
|
6ac36be04b | ||
|
|
8ed1588fdb | ||
|
|
762255443b | ||
|
|
fdf38b0a6a | ||
|
|
be80741314 | ||
|
|
7efb6d2adb | ||
|
|
33f8221d5c | ||
|
|
f7eeb855aa | ||
|
|
a87a4ff09f | ||
|
|
fbb6cf4dd3 | ||
|
|
cceae92f97 | ||
|
|
2f314c3588 | ||
|
|
84fd2c46f6 | ||
|
|
31dd757729 | ||
|
|
cb79036d96 | ||
|
|
32a208eec5 | ||
|
|
6cbe1be5c5 | ||
|
|
c7ae51b952 | ||
|
|
df11beff8c | ||
|
|
c14da33e5b | ||
|
|
f1ce541885 | ||
|
|
07e003fe43 | ||
|
|
81f53c9a7f | ||
|
|
2d8cea2e7d | ||
|
|
b724cfc775 | ||
|
|
10ff2cc092 | ||
|
|
4124c03b80 | ||
|
|
56a3994a52 | ||
|
|
e1e730e439 | ||
|
|
bb17647954 | ||
|
|
56a0baebeb | ||
|
|
d2a6c67e4e | ||
|
|
56f70d015d | ||
|
|
cf9f84767c | ||
|
|
3a862cbd0c | ||
|
|
6af2a99680 | ||
|
|
b3d37d134a | ||
|
|
a9e561ee51 | ||
|
|
e808b1709e | ||
|
|
d75b58e4d8 | ||
|
|
e2430cdcab | ||
|
|
8e6ac8de10 | ||
|
|
5495877e5a | ||
|
|
5078b3776e | ||
|
|
f5d6b8b4d8 | ||
|
|
1c560dbc16 | ||
|
|
4b8b0ed74c | ||
|
|
308d825db7 | ||
|
|
af074c5704 | ||
|
|
c60afdd8fe | ||
|
|
a1d05ca5b3 | ||
|
|
327ca3806a | ||
|
|
2f71dd3927 | ||
|
|
3844edd49f | ||
|
|
8f97a7e81d | ||
|
|
5daf1f0d6f | ||
|
|
b1a5b92ce4 | ||
|
|
c99a70831a | ||
|
|
4b0468b0d2 | ||
|
|
f32078f270 | ||
|
|
a525c073b9 | ||
|
|
afceb92a55 | ||
|
|
4822894efb | ||
|
|
d9b51c3a50 | ||
|
|
15b1dba558 | ||
|
|
fd6b3930c1 | ||
|
|
53cb160a6e | ||
|
|
bb590f140d | ||
|
|
945992b80e | ||
|
|
b8de9ce2b6 | ||
|
|
2c7bce31d4 | ||
|
|
004a5f18de | ||
|
|
731d57d355 | ||
|
|
8c6ff1a6a4 | ||
|
|
f7630b3574 | ||
|
|
76bfe26561 | ||
|
|
7079ea66aa | ||
|
|
6562351955 | ||
|
|
986fdda008 | ||
|
|
af2dc66113 | ||
|
|
cca4a3a37e | ||
|
|
75ec050c31 | ||
|
|
db604e4c41 | ||
|
|
05c48b3d28 | ||
|
|
3bb98c9c27 | ||
|
|
7f4dcce3cb | ||
|
|
766451d9bb | ||
|
|
6f5a2181b2 | ||
|
|
297adbb818 | ||
|
|
13eeb2cf6d | ||
|
|
e9ad65fef6 | ||
|
|
ddb6b5af1e | ||
|
|
c1171d4c7b | ||
|
|
21daccf6ed | ||
|
|
2eed15b4b2 | ||
|
|
de7fdfc4b4 | ||
|
|
709ed12259 | ||
|
|
0826bbb435 | ||
|
|
ec87eb593e | ||
|
|
ecbd50dde4 | ||
|
|
4dd7640452 | ||
|
|
0b08521e63 | ||
|
|
59e768c447 | ||
|
|
6a37b8bbc6 | ||
|
|
9397a781b5 | ||
|
|
255a4730e7 | ||
|
|
de0d1e1912 | ||
|
|
dd50f95583 | ||
|
|
e57376c461 | ||
|
|
3a5a558837 | ||
|
|
506ab33b11 | ||
|
|
198d9c365a | ||
|
|
fbc17356e0 | ||
|
|
a04a28049e | ||
|
|
65267b3c90 | ||
|
|
2196733133 | ||
|
|
67348b42b1 | ||
|
|
e754b2bdc9 | ||
|
|
87e49bc897 | ||
|
|
53212b8669 | ||
|
|
ce7549bb25 | ||
|
|
b5ff5a468e | ||
|
|
b1f9ec43de | ||
|
|
eed2dfb811 | ||
|
|
b7fa6c0405 | ||
|
|
c8d145f52e | ||
|
|
aeacd913f5 | ||
|
|
67b78abfce | ||
|
|
e3b882bdf9 | ||
|
|
6d19413025 | ||
|
|
2aad02a914 | ||
|
|
76baf87c29 | ||
|
|
2a75f863f8 | ||
|
|
262bc57a21 | ||
|
|
9563ae9dcc | ||
|
|
349b215d3d | ||
|
|
7639191c50 | ||
|
|
c3224d30c6 | ||
|
|
40d80fe535 | ||
|
|
ce1a00bed9 | ||
|
|
7df88f5bf7 | ||
|
|
eeb42b1d20 | ||
|
|
23475fb1ce | ||
|
|
fadd84606a | ||
|
|
d3e1a96702 | ||
|
|
91fd44cccf | ||
|
|
5b6f45c896 | ||
|
|
c924259fc0 | ||
|
|
f896f2a071 | ||
|
|
1851a8de71 | ||
|
|
53dd266f42 | ||
|
|
5e05d25c2b | ||
|
|
2d57015ac5 | ||
|
|
579dab56c2 | ||
|
|
f1fea53af6 | ||
|
|
aabae00970 | ||
|
|
9136569809 | ||
|
|
f2bcbe5123 | ||
|
|
3dcb792a55 | ||
|
|
5ca996d2d2 | ||
|
|
9ea1c3a92e | ||
|
|
af85401a69 | ||
|
|
5d3af6d107 | ||
|
|
68ab65764e | ||
|
|
514bea824a | ||
|
|
de874fc8c5 | ||
|
|
14ba1e779c | ||
|
|
0c1e269718 | ||
|
|
a96f5c332c | ||
|
|
a0b8d74582 | ||
|
|
e6166a1de3 | ||
|
|
ae797e5fb1 | ||
|
|
9a7d4decff | ||
|
|
fa29515095 | ||
|
|
34f9d2a663 | ||
|
|
90d161c1b5 | ||
|
|
7a5b6f506e | ||
|
|
c49346f6cc | ||
|
|
39a398aa2b | ||
|
|
0b7c52523e | ||
|
|
cb63f105aa | ||
|
|
316e46de4b | ||
|
|
1af5182b59 | ||
|
|
7b2590e54e |
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -43,6 +43,21 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install cross-platform native binaries
|
||||
shell: bash
|
||||
run: |
|
||||
# npm ci only installs optional deps for the host platform, but
|
||||
# electron-builder produces both arm64 and x64 binaries, so we
|
||||
# need the native codex-acp binary for the other architecture too.
|
||||
# Platform-specific codex-acp packages declare cpu/os constraints,
|
||||
# so --force is needed to install the non-host-arch binary.
|
||||
CODEX_VER=$(node -e "console.log(require('./node_modules/@zed-industries/codex-acp/package.json').version)")
|
||||
if [[ "${{ matrix.name }}" == "macos" ]]; then
|
||||
npm install "@zed-industries/codex-acp-darwin-x64@${CODEX_VER}" "@zed-industries/codex-acp-darwin-arm64@${CODEX_VER}" --no-save --force
|
||||
elif [[ "${{ matrix.name }}" == "windows" ]]; then
|
||||
npm install "@zed-industries/codex-acp-win32-x64@${CODEX_VER}" "@zed-industries/codex-acp-win32-arm64@${CODEX_VER}" --no-save --force
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
|
||||
|
||||
## How Things Talk
|
||||
- UI calls application hooks → hooks call domain helpers → persistence/config via infrastructure adapters.
|
||||
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
|
||||
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
|
||||
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
|
||||
|
||||
@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
|
||||
- Avoid direct network/fetch in components; add a service/adaptor first.
|
||||
- Maintain ASCII-only unless required by existing file content.
|
||||
|
||||
## Review Boundaries
|
||||
- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise.
|
||||
- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents.
|
||||
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
|
||||
- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract.
|
||||
|
||||
---
|
||||
|
||||
## Aside Panel Design System
|
||||
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
|
||||
|
||||
Import from `./ui/aside-panel`:
|
||||
```tsx
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelHeader,
|
||||
AsidePanelContent,
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelHeader,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
AsideActionMenu,
|
||||
AsideActionMenuItem
|
||||
AsideActionMenuItem
|
||||
} from "./ui/aside-panel";
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
```tsx
|
||||
<AsidePanel
|
||||
open={isOpen}
|
||||
<AsidePanel
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Panel Title"
|
||||
subtitle="Optional subtitle"
|
||||
@@ -21,6 +21,7 @@ const en: Messages = {
|
||||
'common.clear': 'Clear',
|
||||
'common.optional': 'Optional',
|
||||
'common.selectPlaceholder': 'Select...',
|
||||
'common.add': 'Add',
|
||||
'common.rename': 'Rename',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.continue': 'Continue',
|
||||
@@ -196,6 +197,15 @@ const en: Messages = {
|
||||
'settings.application.github.subtitle': 'Source code',
|
||||
'settings.application.whatsNew': "What's new",
|
||||
'settings.application.whatsNew.subtitle': 'Show release notes',
|
||||
'settings.application.openExternal.failedTitle': 'Cannot open link',
|
||||
'settings.application.openExternal.failedBody': 'The link could not be opened in either the system browser or the built-in browser window.',
|
||||
'settings.vault.title': 'Vault',
|
||||
'settings.vault.showRecentHosts': 'Show recently connected hosts',
|
||||
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'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.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -233,9 +243,9 @@ const en: 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.',
|
||||
'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.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* 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; }',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -247,6 +257,8 @@ const en: Messages = {
|
||||
'settings.terminal.themeModal.darkThemes': 'Dark Themes',
|
||||
'settings.terminal.themeModal.lightThemes': 'Light Themes',
|
||||
'settings.terminal.theme.selectButton': 'Select Theme',
|
||||
'settings.terminal.theme.followApp': 'Follow Application Theme',
|
||||
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
|
||||
'settings.terminal.section.font': 'Font',
|
||||
'settings.terminal.section.cursor': 'Cursor',
|
||||
'settings.terminal.section.keyboard': 'Keyboard',
|
||||
@@ -324,6 +336,14 @@ const en: Messages = {
|
||||
'settings.terminal.scrollback.rows': 'Number of rows *',
|
||||
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
|
||||
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
|
||||
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
|
||||
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
|
||||
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'settings.terminal.keywordHighlight.preview': 'Preview',
|
||||
'settings.terminal.section.localShell': 'Local Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell executable',
|
||||
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
|
||||
@@ -331,6 +351,11 @@ const en: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': 'Detected',
|
||||
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
|
||||
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
|
||||
'settings.terminal.localShell.shell.default': 'System Default',
|
||||
'settings.terminal.localShell.shell.custom': 'Custom...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
|
||||
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
|
||||
'settings.terminal.localShell.shell.pathValid': 'Path valid',
|
||||
'settings.terminal.localShell.startDir': 'Starting directory',
|
||||
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
|
||||
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
|
||||
@@ -349,9 +374,25 @@ const en: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': 'Rendering',
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'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 > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
|
||||
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
|
||||
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
|
||||
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': 'Autocomplete',
|
||||
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
|
||||
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
|
||||
'settings.terminal.autocomplete.ghostText': 'Ghost text',
|
||||
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
|
||||
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
|
||||
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
|
||||
@@ -409,6 +450,18 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.autoSync.restoredTitle': 'Vault restored',
|
||||
'sync.autoSync.restoredMessage': 'Your vault has been restored from the cloud.',
|
||||
'sync.autoSync.keptLocalTitle': 'Kept local vault',
|
||||
'sync.autoSync.keptLocalMessage': 'Your empty local vault was kept. Cloud data was not applied.',
|
||||
'sync.autoSync.emptyVaultConflict.title': 'Empty Vault Detected',
|
||||
'sync.autoSync.emptyVaultConflict.description': 'Your local vault is empty, but the cloud has data. This usually happens after an update or storage reset. What would you like to do?',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': 'Cloud',
|
||||
'sync.autoSync.emptyVaultConflict.restore': 'Restore from Cloud',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
@@ -442,8 +495,24 @@ const en: Messages = {
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
'vault.groups.pathLabel': 'Path',
|
||||
'vault.groups.settings': 'Group Settings',
|
||||
'vault.groups.details': 'Group Details',
|
||||
'vault.groups.details.general': 'General',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': 'Advanced',
|
||||
'vault.groups.details.appearance': 'Appearance',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': 'Parent Group',
|
||||
'vault.groups.details.none': 'None',
|
||||
'vault.groups.details.inherited': 'Inherited from group',
|
||||
'vault.groups.details.addProtocol': 'Add Protocol',
|
||||
'vault.groups.details.removeProtocol': 'Remove Protocol',
|
||||
'vault.groups.details.fontFamily': 'Font Family',
|
||||
'vault.groups.details.fontSize': 'Font Size',
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
@@ -467,6 +536,10 @@ const en: Messages = {
|
||||
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
|
||||
'vault.hosts.export.toast.noHosts': 'No hosts to export',
|
||||
'vault.hosts.allHosts': 'All hosts',
|
||||
'vault.hosts.pinned': 'Pinned',
|
||||
'vault.hosts.recentlyConnected': 'Recently Connected',
|
||||
'vault.hosts.pinToTop': 'Pin to Top',
|
||||
'vault.hosts.unpin': 'Unpin',
|
||||
'vault.hosts.copyCredentials': 'Copy Credentials',
|
||||
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
|
||||
@@ -476,6 +549,7 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
@@ -601,6 +675,8 @@ const en: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
@@ -617,8 +693,21 @@ const en: Messages = {
|
||||
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
|
||||
'sftp.retry': 'Retry',
|
||||
'sftp.context.open': 'Open',
|
||||
'sftp.context.navigateTo': 'Navigate to',
|
||||
'sftp.context.moveTo': 'Move to...',
|
||||
'sftp.context.moveToParent': 'Move to parent directory',
|
||||
'sftp.moveTo.title': 'Move to directory',
|
||||
'sftp.moveTo.placeholder': 'Enter target directory path',
|
||||
'sftp.moveTo.confirm': 'Move',
|
||||
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
|
||||
'sftp.context.download': 'Download',
|
||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
'sftp.context.rename': 'Rename',
|
||||
'sftp.context.permissions': 'Permissions',
|
||||
'sftp.context.delete': 'Delete',
|
||||
@@ -639,6 +728,13 @@ const en: Messages = {
|
||||
'sftp.transfers.active': '{count} active',
|
||||
'sftp.transfers.clearCompleted': 'Clear completed',
|
||||
'sftp.transfers.calculatingTotal': 'Calculating total size...',
|
||||
'sftp.transfers.filesCount': '{count} files',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} files',
|
||||
'sftp.transfers.expandChildren': 'Show files',
|
||||
'sftp.transfers.collapseChildren': 'Hide files',
|
||||
'sftp.transfers.expandChildList': 'Show detail',
|
||||
'sftp.transfers.collapseChildList': 'Hide',
|
||||
'sftp.transfers.dragToResize': 'Drag to resize',
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.encoding.label': 'Filename Encoding',
|
||||
@@ -658,6 +754,9 @@ const en: Messages = {
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Path',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
'sftp.error.downloadFailed': 'Download failed',
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
@@ -712,6 +811,7 @@ const en: Messages = {
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': 'Copy file path',
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
'sftp.context.preview': 'Preview',
|
||||
@@ -748,6 +848,15 @@ const en: Messages = {
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
|
||||
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
|
||||
'settings.sftp.defaultOpener': 'Default File Opener',
|
||||
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
|
||||
'settings.sftp.defaultOpener.ask': 'Always ask',
|
||||
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
|
||||
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
|
||||
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
|
||||
'settings.sftpFileAssociations.title': 'SFTP File Associations',
|
||||
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
|
||||
'settings.sftpFileAssociations.extension': 'Extension',
|
||||
@@ -776,6 +885,13 @@ const en: Messages = {
|
||||
'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.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.',
|
||||
'settings.sftp.defaultViewMode.list': 'List View',
|
||||
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
|
||||
'settings.sftp.defaultViewMode.tree': 'Tree View',
|
||||
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
@@ -821,6 +937,8 @@ const en: Messages = {
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
'qs.default': 'Default',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': 'Select Host',
|
||||
@@ -886,6 +1004,14 @@ const en: Messages = {
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': 'Cisco',
|
||||
'hostDetails.distro.option.juniper': 'Juniper Networks',
|
||||
'hostDetails.distro.option.huawei': 'Huawei',
|
||||
'hostDetails.distro.option.hpe': 'HPE / H3C',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': 'Fortinet',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': 'ZyXEL',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': 'Username',
|
||||
'hostDetails.password.placeholder': 'Password',
|
||||
@@ -910,10 +1036,16 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.deviceType': 'Device Type',
|
||||
'hostDetails.deviceType': 'Network Device Mode',
|
||||
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -1024,7 +1156,7 @@ const en: Messages = {
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': 'Search terminal',
|
||||
'terminal.toolbar.search': 'Search',
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
@@ -1121,8 +1253,14 @@ const en: Messages = {
|
||||
'terminal.themeModal.globalTheme': 'Global Theme',
|
||||
'terminal.themeModal.globalFont': 'Global Font',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.fontWeight': 'Font Weight',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
'terminal.hiddenTheme.title': 'Current hidden theme',
|
||||
'terminal.hiddenTheme.desc': 'This theme is hidden from manual picks and will be replaced when you choose another theme.',
|
||||
'topTabs.toggleTheme.systemExitTitle': 'System theme is active',
|
||||
'topTabs.toggleTheme.systemExitMessage': 'Open Settings to choose a fixed Light or Dark theme.',
|
||||
'topTabs.toggleTheme.openSettings': 'Open Settings',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
@@ -1252,6 +1390,22 @@ const en: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
'cloudSync.revisionHistory.empty': 'No revisions found.',
|
||||
'cloudSync.revisionHistory.current': 'Current',
|
||||
'cloudSync.revisionHistory.revision': 'Revision',
|
||||
'cloudSync.revisionHistory.revisionPreview': 'Revision Contents',
|
||||
'cloudSync.revisionHistory.device': 'Device',
|
||||
'cloudSync.revisionHistory.hosts': 'Hosts',
|
||||
'cloudSync.revisionHistory.keys': 'Keys',
|
||||
'cloudSync.revisionHistory.snippets': 'Snippets',
|
||||
'cloudSync.revisionHistory.identities': 'Identities',
|
||||
'cloudSync.revisionHistory.restoreButton': 'Restore This Version',
|
||||
'cloudSync.revisionHistory.restored': 'Vault restored from selected revision.',
|
||||
'cloudSync.revisionHistory.revisionNotFound': 'Revision not found or does not contain vault data.',
|
||||
'cloudSync.revisionHistory.decryptFailed': 'Cannot decrypt this revision. It may have been encrypted with a different master password.',
|
||||
'cloudSync.changeKey.title': 'Change Master Key',
|
||||
'cloudSync.changeKey.current': 'Current Master Key',
|
||||
'cloudSync.changeKey.new': 'New Master Key',
|
||||
@@ -1518,6 +1672,7 @@ const en: Messages = {
|
||||
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.charset': 'Charset',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
@@ -1538,10 +1693,7 @@ const en: Messages = {
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.fill': 'Fill',
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
@@ -1584,16 +1736,24 @@ const en: Messages = {
|
||||
'ai.providers.noMatchingModels': 'No matching models',
|
||||
'ai.providers.clickToLoadModels': 'Click to load models',
|
||||
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
|
||||
'ai.providers.advancedParams': 'Advanced Parameters',
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
|
||||
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT here, or enable an OpenAI-compatible provider API key and custom endpoint in Settings.',
|
||||
'ai.codex.detecting': 'Detecting...',
|
||||
'ai.codex.notFound': 'Not found',
|
||||
'ai.codex.awaitingLogin': 'Awaiting login',
|
||||
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
|
||||
'ai.codex.connectedApiKey': 'Connected via API key',
|
||||
'ai.codex.connectedCustomConfig': 'Connected via ~/.codex/config.toml',
|
||||
'ai.codex.customConfigIncomplete': 'Custom config detected (env var missing)',
|
||||
'ai.codex.customConfigHint': 'Using custom provider "{provider}" configured in ~/.codex/config.toml — no ChatGPT login needed.',
|
||||
'ai.codex.customConfigMissingEnvKey': 'Warning: {envKey} is not set in your shell environment. Export it (or launch netcatty from a shell that has it) so Codex can authenticate.',
|
||||
'ai.codex.notConnected': 'Not connected',
|
||||
'ai.codex.statusUnknown': 'Status unknown',
|
||||
'ai.codex.path': 'Path:',
|
||||
@@ -1604,7 +1764,7 @@ const en: Messages = {
|
||||
'ai.codex.logout': 'Logout',
|
||||
'ai.codex.connectChatGPT': 'Connect ChatGPT',
|
||||
'ai.codex.refreshStatus': 'Refresh Status',
|
||||
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
|
||||
'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1617,10 +1777,26 @@ const en: Messages = {
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||
'ai.toolAccess.title': 'Tool Access',
|
||||
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
@@ -1697,7 +1873,7 @@ const en: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': 'Safety',
|
||||
'ai.safety.permissionMode': 'Permission Mode',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
|
||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||
@@ -1707,7 +1883,7 @@ const en: Messages = {
|
||||
'ai.safety.maxIterations': 'Max Iterations',
|
||||
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
|
||||
'ai.safety.blocklist': 'Command Blocklist',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'ai.safety.blocklist.add': 'Add pattern',
|
||||
|
||||
@@ -13,6 +13,7 @@ const zhCN: Messages = {
|
||||
'common.connect': '连接',
|
||||
'common.terminal': '终端',
|
||||
'common.create': '创建',
|
||||
'common.add': '添加',
|
||||
'common.rename': '重命名',
|
||||
'common.refresh': '刷新',
|
||||
'common.continue': '继续',
|
||||
@@ -180,6 +181,15 @@ const zhCN: Messages = {
|
||||
'settings.application.github.subtitle': '源代码',
|
||||
'settings.application.whatsNew': '更新内容',
|
||||
'settings.application.whatsNew.subtitle': '查看发布说明',
|
||||
'settings.application.openExternal.failedTitle': '无法打开链接',
|
||||
'settings.application.openExternal.failedBody': '系统浏览器和内置浏览器窗口都无法打开该链接。',
|
||||
'settings.vault.title': '主机库',
|
||||
'settings.vault.showRecentHosts': '显示最近连接的主机',
|
||||
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -216,9 +226,10 @@ const zhCN: Messages = {
|
||||
'settings.appearance.themeColor.light': '浅色主题',
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc': '使用自定义 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。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 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; }',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -258,6 +269,18 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.autoSync.restoredTitle': '已恢复',
|
||||
'sync.autoSync.restoredMessage': '已从云端恢复主机库数据。',
|
||||
'sync.autoSync.keptLocalTitle': '已保留本地数据',
|
||||
'sync.autoSync.keptLocalMessage': '保留了空的本地主机库,未应用云端数据。',
|
||||
'sync.autoSync.emptyVaultConflict.title': '检测到空主机库',
|
||||
'sync.autoSync.emptyVaultConflict.description': '本地主机库为空,但云端有数据。这通常发生在应用更新或存储重置之后。请选择如何处理:',
|
||||
'sync.autoSync.emptyVaultConflict.cloudLabel': '云端',
|
||||
'sync.autoSync.emptyVaultConflict.restore': '从云端恢复',
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
@@ -291,8 +314,24 @@ const zhCN: Messages = {
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
'vault.groups.pathLabel': '路径',
|
||||
'vault.groups.settings': '分组设置',
|
||||
'vault.groups.details': '分组详情',
|
||||
'vault.groups.details.general': '常规',
|
||||
'vault.groups.details.ssh': 'SSH',
|
||||
'vault.groups.details.telnet': 'Telnet',
|
||||
'vault.groups.details.advanced': '高级',
|
||||
'vault.groups.details.appearance': '外观',
|
||||
'vault.groups.details.mosh': 'Mosh',
|
||||
'vault.groups.details.parentGroup': '父分组',
|
||||
'vault.groups.details.none': '无',
|
||||
'vault.groups.details.inherited': '继承自分组',
|
||||
'vault.groups.details.addProtocol': '添加协议',
|
||||
'vault.groups.details.removeProtocol': '移除协议',
|
||||
'vault.groups.details.fontFamily': '字体',
|
||||
'vault.groups.details.fontSize': '字号',
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
@@ -316,6 +355,10 @@ const zhCN: Messages = {
|
||||
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
|
||||
'vault.hosts.export.toast.noHosts': '没有主机可导出',
|
||||
'vault.hosts.allHosts': '全部主机',
|
||||
'vault.hosts.pinned': '已置顶',
|
||||
'vault.hosts.recentlyConnected': '最近连接',
|
||||
'vault.hosts.pinToTop': '置顶',
|
||||
'vault.hosts.unpin': '取消置顶',
|
||||
'vault.hosts.copyCredentials': '复制账密信息',
|
||||
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
|
||||
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
|
||||
@@ -325,6 +368,7 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -425,6 +469,8 @@ const zhCN: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
@@ -441,8 +487,21 @@ const zhCN: Messages = {
|
||||
'sftp.dragDropToUpload': '拖拽文件到这里上传',
|
||||
'sftp.retry': '重试',
|
||||
'sftp.context.open': '打开',
|
||||
'sftp.context.navigateTo': '跳转到这里',
|
||||
'sftp.context.moveTo': '移动到...',
|
||||
'sftp.context.moveToParent': '移动到上级目录',
|
||||
'sftp.moveTo.title': '移动到目录',
|
||||
'sftp.moveTo.placeholder': '输入目标目录路径',
|
||||
'sftp.moveTo.confirm': '移动',
|
||||
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
|
||||
'sftp.context.download': '下载',
|
||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
'sftp.context.rename': '重命名',
|
||||
'sftp.context.permissions': '权限',
|
||||
'sftp.context.delete': '删除',
|
||||
@@ -463,6 +522,13 @@ const zhCN: Messages = {
|
||||
'sftp.transfers.active': '{count} 个进行中',
|
||||
'sftp.transfers.clearCompleted': '清除已完成',
|
||||
'sftp.transfers.calculatingTotal': '正在统计总大小...',
|
||||
'sftp.transfers.filesCount': '{count} 个文件',
|
||||
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
|
||||
'sftp.transfers.expandChildren': '展开文件',
|
||||
'sftp.transfers.collapseChildren': '收起文件',
|
||||
'sftp.transfers.expandChildList': '展开详情',
|
||||
'sftp.transfers.collapseChildList': '收起',
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.encoding.label': '文件名编码',
|
||||
@@ -482,6 +548,9 @@ const zhCN: Messages = {
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
|
||||
'sftp.deleteConfirm.host': '主机',
|
||||
'sftp.deleteConfirm.path': '路径',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
'sftp.error.downloadFailed': '下载失败',
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
@@ -514,6 +583,8 @@ const zhCN: Messages = {
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
'qs.default': '默认',
|
||||
|
||||
// Select Host panel
|
||||
'selectHost.title': '选择主机',
|
||||
@@ -575,6 +646,14 @@ const zhCN: Messages = {
|
||||
'hostDetails.distro.option.almalinux': 'AlmaLinux',
|
||||
'hostDetails.distro.option.oracle': 'Oracle Linux',
|
||||
'hostDetails.distro.option.kali': 'Kali Linux',
|
||||
'hostDetails.distro.option.cisco': '思科',
|
||||
'hostDetails.distro.option.juniper': '瞻博网络',
|
||||
'hostDetails.distro.option.huawei': '华为',
|
||||
'hostDetails.distro.option.hpe': '慧与 / H3C',
|
||||
'hostDetails.distro.option.mikrotik': 'MikroTik',
|
||||
'hostDetails.distro.option.fortinet': '飞塔',
|
||||
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
|
||||
'hostDetails.distro.option.zyxel': '合勤',
|
||||
'hostDetails.section.mosh': 'Mosh',
|
||||
'hostDetails.username.placeholder': '用户名',
|
||||
'hostDetails.password.placeholder': '密码',
|
||||
@@ -599,10 +678,16 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent(如 Bitwarden、1Password、gpg-agent)。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.deviceType': '设备类型',
|
||||
'hostDetails.deviceType': '网络设备模式',
|
||||
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -684,7 +769,7 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端 (Ctrl+F)',
|
||||
'terminal.toolbar.searchTerminal': '搜索终端',
|
||||
'terminal.toolbar.search': '搜索',
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
@@ -782,8 +867,14 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.globalTheme': '全局主题',
|
||||
'terminal.themeModal.globalFont': '全局字体',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.fontWeight': '字体粗细',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
'terminal.hiddenTheme.title': '当前隐藏主题',
|
||||
'terminal.hiddenTheme.desc': '这个主题已从手动选择列表中隐藏;当你选择其他可见主题后,它会被替换。',
|
||||
'topTabs.toggleTheme.systemExitTitle': '当前正在跟随系统主题',
|
||||
'topTabs.toggleTheme.systemExitMessage': '请到设置里选择固定的浅色或深色主题。',
|
||||
'topTabs.toggleTheme.openSettings': '打开设置',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自定义主题',
|
||||
@@ -912,6 +1003,22 @@ const zhCN: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
'cloudSync.revisionHistory.empty': '未找到修订记录。',
|
||||
'cloudSync.revisionHistory.current': '当前版本',
|
||||
'cloudSync.revisionHistory.revision': '修订',
|
||||
'cloudSync.revisionHistory.revisionPreview': '修订内容',
|
||||
'cloudSync.revisionHistory.device': '设备',
|
||||
'cloudSync.revisionHistory.hosts': '主机',
|
||||
'cloudSync.revisionHistory.keys': '密钥',
|
||||
'cloudSync.revisionHistory.snippets': '代码片段',
|
||||
'cloudSync.revisionHistory.identities': '身份',
|
||||
'cloudSync.revisionHistory.restoreButton': '恢复此版本',
|
||||
'cloudSync.revisionHistory.restored': '已从选中的修订恢复主机库数据。',
|
||||
'cloudSync.revisionHistory.revisionNotFound': '修订未找到或不包含主机库数据。',
|
||||
'cloudSync.revisionHistory.decryptFailed': '无法解密此修订。可能是使用了不同的主密钥加密的。',
|
||||
'cloudSync.changeKey.title': '更改主密钥',
|
||||
'cloudSync.changeKey.current': '当前主密钥',
|
||||
'cloudSync.changeKey.new': '新的主密钥',
|
||||
@@ -1053,6 +1160,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
'sftp.context.preview': '预览',
|
||||
@@ -1089,6 +1197,15 @@ const zhCN: Messages = {
|
||||
|
||||
// Settings > SFTP File Associations
|
||||
'settings.tab.sftpFileAssociations': 'SFTP',
|
||||
'settings.sftp.transferConcurrency': '传输并发数',
|
||||
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
|
||||
'settings.sftp.defaultOpener': '默认文件打开方式',
|
||||
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
|
||||
'settings.sftp.defaultOpener.ask': '每次询问',
|
||||
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
|
||||
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
|
||||
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
|
||||
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
|
||||
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
|
||||
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
|
||||
'settings.sftpFileAssociations.extension': '扩展名',
|
||||
@@ -1117,6 +1234,13 @@ const zhCN: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
|
||||
'settings.sftp.defaultViewMode.tree': '树形视图',
|
||||
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
@@ -1164,6 +1288,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.themeModal.darkThemes': '深色主题',
|
||||
'settings.terminal.themeModal.lightThemes': '浅色主题',
|
||||
'settings.terminal.theme.selectButton': '选择主题',
|
||||
'settings.terminal.theme.followApp': '跟随应用主题',
|
||||
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
|
||||
'settings.terminal.section.font': '字体',
|
||||
'settings.terminal.section.cursor': '光标',
|
||||
'settings.terminal.section.keyboard': '键盘',
|
||||
@@ -1234,6 +1360,14 @@ const zhCN: Messages = {
|
||||
'settings.terminal.scrollback.rows': '行数 *',
|
||||
'settings.terminal.keywordHighlight.title': '关键字高亮',
|
||||
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
|
||||
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
|
||||
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
|
||||
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
|
||||
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down)',
|
||||
'settings.terminal.keywordHighlight.patternField': '正则表达式',
|
||||
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b)',
|
||||
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
|
||||
'settings.terminal.keywordHighlight.preview': '预览',
|
||||
'settings.terminal.section.localShell': '本地 Shell',
|
||||
'settings.terminal.localShell.shell': 'Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe)。留空使用系统默认。',
|
||||
@@ -1241,6 +1375,11 @@ const zhCN: Messages = {
|
||||
'settings.terminal.localShell.shell.detected': '检测到',
|
||||
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
|
||||
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
|
||||
'settings.terminal.localShell.shell.default': '系统默认',
|
||||
'settings.terminal.localShell.shell.custom': '自定义...',
|
||||
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
|
||||
'settings.terminal.localShell.shell.commonPaths': '常用路径',
|
||||
'settings.terminal.localShell.shell.pathValid': '路径有效',
|
||||
'settings.terminal.localShell.startDir': '起始目录',
|
||||
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
|
||||
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
|
||||
@@ -1259,9 +1398,18 @@ const zhCN: Messages = {
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
'settings.terminal.autocomplete.enabled': '启用自动补全',
|
||||
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
|
||||
'settings.terminal.autocomplete.ghostText': '行内建议',
|
||||
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell)。',
|
||||
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
|
||||
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
|
||||
|
||||
// Settings > Shortcuts
|
||||
'settings.shortcuts.section.scheme': '快捷键方案',
|
||||
'settings.shortcuts.scheme.label': '键盘快捷键',
|
||||
@@ -1532,6 +1680,7 @@ const zhCN: Messages = {
|
||||
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.charset': '字符编码',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
@@ -1552,10 +1701,7 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.fill': '填入',
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
@@ -1598,16 +1744,24 @@ const zhCN: Messages = {
|
||||
'ai.providers.noMatchingModels': '没有匹配的模型',
|
||||
'ai.providers.clickToLoadModels': '点击加载模型',
|
||||
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
|
||||
'ai.providers.advancedParams': '高级参数',
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
'ai.codex.title': 'Codex CLI',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key(将作为 CODEX_API_KEY 传递)。',
|
||||
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。可以在这里连接 ChatGPT,也可以在设置里启用兼容 OpenAI 的 API Key 和自定义接口地址。',
|
||||
'ai.codex.detecting': '检测中...',
|
||||
'ai.codex.notFound': '未找到',
|
||||
'ai.codex.awaitingLogin': '等待登录',
|
||||
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
|
||||
'ai.codex.connectedApiKey': '已通过 API Key 连接',
|
||||
'ai.codex.connectedCustomConfig': '使用 ~/.codex/config.toml 自定义 provider',
|
||||
'ai.codex.customConfigIncomplete': '检测到自定义配置(缺少环境变量)',
|
||||
'ai.codex.customConfigHint': '使用 ~/.codex/config.toml 中配置的自定义 provider "{provider}",无需 ChatGPT 登录。',
|
||||
'ai.codex.customConfigMissingEnvKey': '警告:环境变量 {envKey} 未在当前 shell 中设置。请 export 它(或从包含该变量的 shell 启动 netcatty),否则 Codex 无法鉴权。',
|
||||
'ai.codex.notConnected': '未连接',
|
||||
'ai.codex.statusUnknown': '状态未知',
|
||||
'ai.codex.path': '路径:',
|
||||
@@ -1618,7 +1772,7 @@ const zhCN: Messages = {
|
||||
'ai.codex.logout': '退出登录',
|
||||
'ai.codex.connectChatGPT': '连接 ChatGPT',
|
||||
'ai.codex.refreshStatus': '刷新状态',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
|
||||
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
|
||||
|
||||
// AI Claude Code
|
||||
'ai.claude.title': 'Claude Code',
|
||||
@@ -1631,10 +1785,26 @@ const zhCN: Messages = {
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||
'ai.toolAccess.title': '工具接入',
|
||||
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||
'ai.toolAccess.mode.mcp': 'MCP',
|
||||
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
@@ -1711,7 +1881,7 @@ const zhCN: Messages = {
|
||||
// AI Safety Settings
|
||||
'ai.safety.title': '安全',
|
||||
'ai.safety.permissionMode': '权限模式',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||
@@ -1721,7 +1891,7 @@ const zhCN: Messages = {
|
||||
'ai.safety.maxIterations': '最大迭代次数',
|
||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||
'ai.safety.blocklist': '命令黑名单',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
|
||||
38
application/notification.ts
Normal file
38
application/notification.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Application-layer notification port.
|
||||
*
|
||||
* UI layers (e.g. toast) register their implementation via `setNotify`.
|
||||
* Application code calls `notify.*` without importing any UI module.
|
||||
*/
|
||||
|
||||
export interface NotifyOptions {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
|
||||
|
||||
interface Notify {
|
||||
success: NotifyFn;
|
||||
error: NotifyFn;
|
||||
warning: NotifyFn;
|
||||
info: NotifyFn;
|
||||
}
|
||||
|
||||
const noop: NotifyFn = () => {};
|
||||
|
||||
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
|
||||
|
||||
/** Called once by the UI layer to wire up the real implementation. */
|
||||
export function setNotify(impl: Notify): void {
|
||||
_impl = impl;
|
||||
}
|
||||
|
||||
export const notify: Notify = {
|
||||
success: (...args) => _impl.success(...args),
|
||||
error: (...args) => _impl.error(...args),
|
||||
warning: (...args) => _impl.warning(...args),
|
||||
info: (...args) => _impl.info(...args),
|
||||
};
|
||||
@@ -6,6 +6,7 @@ type Listener = () => void;
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private pendingNotify = false;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
|
||||
@@ -13,7 +14,10 @@ class ActiveTabStore {
|
||||
if (this.activeTabId !== id) {
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ class CustomThemeStore {
|
||||
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
|
||||
// Another window changed custom themes — reload from localStorage
|
||||
this.loadFromStorage();
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
@@ -129,6 +128,13 @@ class CustomThemeStore {
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
replaceThemes = (themes: TerminalTheme[]) => {
|
||||
this.themes = themes.map((theme) => ({ ...theme, colors: { ...theme.colors }, isCustom: true }));
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton
|
||||
@@ -172,5 +178,9 @@ export const useCustomThemeActions = () => {
|
||||
customThemeStore.deleteTheme(id);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme };
|
||||
const replaceThemes = useCallback((themes: TerminalTheme[]) => {
|
||||
customThemeStore.replaceThemes(themes);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme, replaceThemes };
|
||||
};
|
||||
|
||||
@@ -68,8 +68,14 @@ class FontStore {
|
||||
// Add default fonts first
|
||||
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
|
||||
|
||||
// Add local fonts with a distinct ID namespace to avoid collisions
|
||||
// Build a set of built-in font family names for dedup (case-insensitive)
|
||||
const builtinFamilyNames = new Set(
|
||||
TERMINAL_FONTS.map(f => f.name.toLowerCase())
|
||||
);
|
||||
|
||||
// Add local fonts, skipping those already covered by built-in fonts
|
||||
localFonts.forEach(font => {
|
||||
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
|
||||
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
|
||||
fontMap.set(localId, { ...font, id: localId });
|
||||
});
|
||||
|
||||
46
application/state/sessionActivity.ts
Normal file
46
application/state/sessionActivity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { TerminalSession } from '../../types';
|
||||
|
||||
type SessionActivityMap = Record<string, boolean>;
|
||||
|
||||
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
|
||||
return new Set(sessions.map((session) => session.id));
|
||||
};
|
||||
|
||||
export const shouldMarkSessionActivity = (
|
||||
activeTabId: string | null,
|
||||
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
|
||||
): boolean => {
|
||||
return activeTabId !== session.id && activeTabId !== session.workspaceId;
|
||||
};
|
||||
|
||||
export const getSessionActivityIdsToClear = (
|
||||
activeTabId: string | null,
|
||||
sessions: TerminalSession[],
|
||||
): string[] => {
|
||||
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) {
|
||||
return [activeSession.id];
|
||||
}
|
||||
|
||||
return sessions
|
||||
.filter((session) => session.workspaceId === activeTabId)
|
||||
.map((session) => session.id);
|
||||
};
|
||||
|
||||
export const buildWorkspaceActivityMap = (
|
||||
sessions: TerminalSession[],
|
||||
sessionActivityMap: SessionActivityMap,
|
||||
): Map<string, boolean> => {
|
||||
const workspaceActivityMap = new Map<string, boolean>();
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
|
||||
workspaceActivityMap.set(session.workspaceId, true);
|
||||
}
|
||||
|
||||
return workspaceActivityMap;
|
||||
};
|
||||
78
application/state/sessionActivityStore.ts
Normal file
78
application/state/sessionActivityStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class SessionActivityStore {
|
||||
private snapshot: Record<string, boolean> = {};
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getSnapshot = () => this.snapshot;
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private emit() {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
setTabActive = (tabId: string, hasActivity: boolean) => {
|
||||
const alreadyActive = !!this.snapshot[tabId];
|
||||
if (alreadyActive === hasActivity) return;
|
||||
|
||||
if (hasActivity) {
|
||||
this.snapshot = { ...this.snapshot, [tabId]: true };
|
||||
} else {
|
||||
const { [tabId]: _removed, ...rest } = this.snapshot;
|
||||
this.snapshot = rest;
|
||||
}
|
||||
|
||||
this.emit();
|
||||
};
|
||||
|
||||
clearTab = (tabId: string) => {
|
||||
this.setTabActive(tabId, false);
|
||||
};
|
||||
|
||||
clearTabs = (tabIds: Iterable<string>) => {
|
||||
let changed = false;
|
||||
const next = { ...this.snapshot };
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
if (!next[tabId]) continue;
|
||||
delete next[tabId];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
|
||||
prune = (validTabIds: Set<string>) => {
|
||||
let changed = false;
|
||||
const next: Record<string, boolean> = {};
|
||||
|
||||
for (const tabId of Object.keys(this.snapshot)) {
|
||||
if (validTabIds.has(tabId)) {
|
||||
next[tabId] = true;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
this.snapshot = next;
|
||||
this.emit();
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionActivityStore = new SessionActivityStore();
|
||||
|
||||
export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export interface SftpPane {
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
showHiddenFiles: boolean;
|
||||
transferMutationToken: number;
|
||||
}
|
||||
|
||||
// Multi-tab state for left and right sides
|
||||
@@ -39,6 +40,7 @@ export const createEmptyPane = (
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles,
|
||||
transferMutationToken: 0,
|
||||
});
|
||||
|
||||
// File watch event types
|
||||
|
||||
@@ -88,6 +88,8 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
const isReconnectAttempt = reconnectingRef.current[side];
|
||||
|
||||
// Notify caller of the tab ID synchronously, before any async work.
|
||||
// This allows callers to map metadata (e.g. connection keys) to the tab
|
||||
// immediately, avoiding race conditions with deferred effects.
|
||||
@@ -466,7 +468,11 @@ export const useSftpConnections = ({
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -496,32 +502,39 @@ export const useSftpConnections = ({
|
||||
!initialConnectDoneRef.current &&
|
||||
leftTabs.tabs.length === 0
|
||||
) {
|
||||
initialConnectDoneRef.current = true;
|
||||
setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
initialConnectDoneRef.current = true;
|
||||
connect("left", "local");
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const attemptReconnect = async (side: "left" | "right") => {
|
||||
const reconnectTimers: number[] = [];
|
||||
|
||||
const scheduleReconnect = (side: "left" | "right") => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && reconnectingRef.current[side]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (reconnectingRef.current[side]) {
|
||||
connect(side, lastHost);
|
||||
}
|
||||
}
|
||||
if (!lastHost || !reconnectingRef.current[side]) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
void connect(side, lastHost);
|
||||
}, 1000);
|
||||
reconnectTimers.push(timer);
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
attemptReconnect("left");
|
||||
scheduleReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
attemptReconnect("right");
|
||||
scheduleReconnect("right");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
|
||||
|
||||
return () => {
|
||||
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (side: "left" | "right") => {
|
||||
|
||||
@@ -45,7 +45,8 @@ interface SftpExternalOperationsResult {
|
||||
activeFileWatchCountRef: React.MutableRefObject<number>;
|
||||
uploadExternalFiles: (
|
||||
side: "left" | "right",
|
||||
dataTransfer: DataTransfer
|
||||
dataTransfer: DataTransfer,
|
||||
targetPath?: string
|
||||
) => Promise<UploadResult[]>;
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
@@ -377,6 +378,7 @@ export const useSftpExternalOperations = (
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
progressMode: "bytes",
|
||||
};
|
||||
addExternalUpload(scanningTask);
|
||||
}
|
||||
@@ -404,6 +406,8 @@ export const useSftpExternalOperations = (
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
progressMode: task.progressMode ?? "bytes",
|
||||
parentTaskId: task.parentTaskId,
|
||||
};
|
||||
addExternalUpload(transferTask);
|
||||
}
|
||||
@@ -505,7 +509,7 @@ export const useSftpExternalOperations = (
|
||||
}, []);
|
||||
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
|
||||
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
@@ -525,13 +529,14 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
|
||||
const uploadPaneId = pane.id;
|
||||
const uploadTargetPath = targetPath || pane.connection.currentPath;
|
||||
// Create a new upload controller for this upload
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks(
|
||||
pane.connection.id,
|
||||
pane.connection.currentPath,
|
||||
uploadTargetPath,
|
||||
pane.connection.isLocal ? undefined : pane.connection.hostId,
|
||||
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
|
||||
);
|
||||
@@ -540,7 +545,7 @@ export const useSftpExternalOperations = (
|
||||
const results = await uploadFromDataTransfer(
|
||||
dataTransfer,
|
||||
{
|
||||
targetPath: pane.connection.currentPath,
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId,
|
||||
isLocal: pane.connection.isLocal,
|
||||
bridge: createUploadBridge,
|
||||
@@ -551,7 +556,14 @@ export const useSftpExternalOperations = (
|
||||
controller
|
||||
);
|
||||
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
// Invalidate cache for the upload target so returning to that path
|
||||
// triggers a fresh listing.
|
||||
if (clearDirCacheEntry && targetPath) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
@@ -561,6 +573,7 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDirCacheEntry,
|
||||
connectionCacheKeyMapRef,
|
||||
getActivePane,
|
||||
refresh,
|
||||
@@ -634,7 +647,9 @@ export const useSftpExternalOperations = (
|
||||
if (clearDirCacheEntry) {
|
||||
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
|
||||
}
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
if (uploadTargetPath === pane.connection.currentPath) {
|
||||
await refresh(side, { tabId: uploadPaneId });
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error("[SFTP] Upload failed:", error);
|
||||
|
||||
@@ -3,9 +3,12 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { SftpPane } from "./types";
|
||||
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
|
||||
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -25,6 +28,7 @@ interface UseSftpPaneActionsParams {
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
@@ -40,7 +44,9 @@ interface UseSftpPaneActionsResult {
|
||||
setFilter: (side: "left" | "right", filter: string) => void;
|
||||
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
@@ -49,6 +55,8 @@ interface UseSftpPaneActionsResult {
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
|
||||
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -71,8 +79,39 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
|
||||
}
|
||||
if (path === "/") return "/";
|
||||
return path.replace(/\/+$/, "");
|
||||
}, []);
|
||||
|
||||
const isSamePath = useCallback((a: string, b: string): boolean => {
|
||||
return normalizePathForCompare(a) === normalizePathForCompare(b);
|
||||
}, [normalizePathForCompare]);
|
||||
|
||||
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
|
||||
const normalizedCandidate = normalizePathForCompare(candidate);
|
||||
const normalizedParent = normalizePathForCompare(parent);
|
||||
if (normalizedCandidate === normalizedParent) return false;
|
||||
|
||||
if (/^[a-z]:\\$/.test(normalizedParent)) {
|
||||
return normalizedCandidate.startsWith(normalizedParent);
|
||||
}
|
||||
|
||||
if (normalizedParent === "/") {
|
||||
return normalizedCandidate.startsWith("/");
|
||||
}
|
||||
|
||||
const separator = normalizedParent.includes("\\") ? "\\" : "/";
|
||||
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
|
||||
}, [normalizePathForCompare]);
|
||||
|
||||
// Build the shared cache key for the active pane. Prefer the last connected
|
||||
// host (which includes session-time overrides), fall back to the vault hosts list.
|
||||
const hostsRef = useRef(hosts);
|
||||
@@ -146,7 +185,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path,
|
||||
files: cached.files,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
});
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
...prev,
|
||||
@@ -156,7 +195,7 @@ export const useSftpPaneActions = ({
|
||||
files: cached.files,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
// Use hostId as the shared cache key — this is safe because the
|
||||
@@ -200,7 +239,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: path }
|
||||
: null,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
loading: true,
|
||||
error: null,
|
||||
}));
|
||||
@@ -270,7 +309,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path,
|
||||
files,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
});
|
||||
|
||||
updateTab(side, targetTabId, (prev) => ({
|
||||
@@ -280,7 +319,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: new Set(),
|
||||
selectedFiles: EMPTY_SET,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||||
@@ -340,6 +379,25 @@ export const useSftpPaneActions = ({
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
: getActivePane(side);
|
||||
if (pane?.connection) {
|
||||
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
|
||||
if (!hasRemoteSession) {
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
error: "sftp.reconnecting.title",
|
||||
}));
|
||||
} else if (!lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
error: "sftp.error.connectionLostManual",
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
@@ -362,7 +420,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
@@ -409,6 +467,10 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
@@ -419,11 +481,15 @@ export const useSftpPaneActions = ({
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
@@ -433,11 +499,11 @@ export const useSftpPaneActions = ({
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
|
||||
}, [updateActiveTab]);
|
||||
|
||||
const selectAll = useCallback(
|
||||
@@ -467,12 +533,12 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const createDirectoryAtPath = useCallback(
|
||||
async (side: "left" | "right", path: string, name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
@@ -485,7 +551,9 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
await refresh(side);
|
||||
if (pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
@@ -497,12 +565,21 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
const createDirectory = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
await createDirectoryAtPath(side, pane.connection.currentPath, name);
|
||||
},
|
||||
[createDirectoryAtPath, getActivePane],
|
||||
);
|
||||
|
||||
const fullPath = joinPath(pane.connection.currentPath, name);
|
||||
const createFileAtPath = useCallback(
|
||||
async (side: "left" | "right", path: string, name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
@@ -529,7 +606,9 @@ export const useSftpPaneActions = ({
|
||||
throw new Error("No write method available");
|
||||
}
|
||||
}
|
||||
await refresh(side);
|
||||
if (pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
@@ -541,6 +620,15 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const createFile = useCallback(
|
||||
async (side: "left" | "right", name: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
await createFileAtPath(side, pane.connection.currentPath, name);
|
||||
},
|
||||
[createFileAtPath, getActivePane],
|
||||
);
|
||||
|
||||
const deleteFiles = useCallback(
|
||||
async (side: "left" | "right", fileNames: string[]) => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -686,6 +774,139 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
// Rename using a full source path (for tree view where entryPath is already absolute).
|
||||
// newName is still a basename; the new path is built as joinPath(parent, newName).
|
||||
const renameFileAtPath = useCallback(
|
||||
async (side: "left" | "right", oldPath: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) return;
|
||||
|
||||
const parentPath = getParentPath(oldPath);
|
||||
const newPath = joinPath(parentPath, newName);
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||||
}
|
||||
if (pane.connection.currentPath === parentPath) {
|
||||
await refresh(side);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const moveEntriesToPath = useCallback(
|
||||
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection || sourcePaths.length === 0) return;
|
||||
|
||||
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
|
||||
const filteredSources = uniqueSources
|
||||
.sort((a, b) => a.length - b.length)
|
||||
.filter((path, index, arr) =>
|
||||
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
|
||||
);
|
||||
|
||||
const movableSources = filteredSources.filter((sourcePath) => {
|
||||
if (isSamePath(sourcePath, targetPath)) return false;
|
||||
if (isDescendantPath(targetPath, sourcePath)) return false;
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
return !isSamePath(destinationPath, sourcePath);
|
||||
});
|
||||
|
||||
if (movableSources.length === 0) return;
|
||||
|
||||
const sourceParentNames = new Map<string, string[]>();
|
||||
for (const sourcePath of movableSources) {
|
||||
const parentPath = getParentPath(sourcePath);
|
||||
const names = sourceParentNames.get(parentPath) ?? [];
|
||||
names.push(getFileName(sourcePath));
|
||||
sourceParentNames.set(parentPath, names);
|
||||
}
|
||||
|
||||
try {
|
||||
if (pane.connection.isLocal) {
|
||||
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
|
||||
if (!renameLocalFile) {
|
||||
throw new Error("Local rename unavailable");
|
||||
}
|
||||
for (const sourcePath of movableSources) {
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
await renameLocalFile(sourcePath, destinationPath);
|
||||
}
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
handleSessionError(side, new Error("SFTP session not found"));
|
||||
return;
|
||||
}
|
||||
const renameSftp = netcattyBridge.get()?.renameSftp;
|
||||
if (!renameSftp) {
|
||||
throw new Error("SFTP rename unavailable");
|
||||
}
|
||||
for (const sourcePath of movableSources) {
|
||||
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||||
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
const currentPath = pane.connection.currentPath;
|
||||
const sourceParents = Array.from(sourceParentNames.keys());
|
||||
const currentPathAffected =
|
||||
sourceParents.some((path) => isSamePath(path, currentPath)) ||
|
||||
isSamePath(targetPath, currentPath);
|
||||
|
||||
if (currentPathAffected) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
|
||||
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const removeSet = new Set(namesInCurrentPath);
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of removeSet) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
|
||||
);
|
||||
|
||||
const changePermissions = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
@@ -730,10 +951,14 @@ export const useSftpPaneActions = ({
|
||||
setFilter,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SftpTabsState {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||||
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -34,6 +35,8 @@ interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const clearSelectionsExcept = useCallback(
|
||||
(target: { side: "left" | "right"; tabId: string } | null) => {
|
||||
const clearSideSelections = (
|
||||
prev: SftpSideTabs,
|
||||
side: "left" | "right",
|
||||
): SftpSideTabs => {
|
||||
let changed = false;
|
||||
const tabs = prev.tabs.map((tab) => {
|
||||
const shouldKeepSelection =
|
||||
target?.side === side && target.tabId === tab.id;
|
||||
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
|
||||
return tab;
|
||||
}
|
||||
changed = true;
|
||||
return { ...tab, selectedFiles: EMPTY_SELECTION };
|
||||
});
|
||||
return changed ? { ...prev, tabs } : prev;
|
||||
};
|
||||
|
||||
setLeftTabs((prev) => clearSideSelections(prev, "left"));
|
||||
setRightTabs((prev) => clearSideSelections(prev, "right"));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ export const joinPath = (base: string, name: string): string => {
|
||||
return `${normalizedBase}\\${name}`;
|
||||
}
|
||||
if (base === "/") return `/${name}`;
|
||||
return `${base}/${name}`;
|
||||
return `${base.replace(/\/+$/, "")}/${name}`;
|
||||
};
|
||||
|
||||
export const getParentPath = (path: string): string => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import type {
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
ProviderConfig,
|
||||
HostAIPermission,
|
||||
ExternalAgentConfig,
|
||||
@@ -29,8 +31,17 @@ import type {
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
@@ -47,27 +58,63 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
|
||||
const separatorIndex = scopeKey.indexOf(':');
|
||||
if (separatorIndex === -1) return true;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return true;
|
||||
|
||||
return activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const removedSessionIds = currentSessions
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (removedSessionIds.length === 0) return;
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
const removedSessionIdSet = new Set(removedSessionIds);
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions.filter((session) => {
|
||||
if (!session.scope.targetId) return true;
|
||||
return activeTargetIds.has(session.scope.targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
const nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
@@ -75,11 +122,10 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (sessionId && removedSessionIdSet.has(sessionId)) {
|
||||
nextActiveSessionIdMap[scopeKey] = null;
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
for (const scopeKey of Object.keys(activeSessionIdMap)) {
|
||||
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
@@ -126,6 +172,19 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -144,6 +203,10 @@ export function useAIState() {
|
||||
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||
return 'confirm';
|
||||
});
|
||||
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||
return stored === 'skills' ? 'skills' : 'mcp';
|
||||
});
|
||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||
);
|
||||
@@ -204,7 +267,7 @@ export function useAIState() {
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
@@ -282,6 +345,13 @@ export function useAIState() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
|
||||
setToolIntegrationModeRaw(mode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}, []);
|
||||
|
||||
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||
setExternalAgentsRaw(prev => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
@@ -348,6 +418,15 @@ export function useAIState() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
|
||||
{
|
||||
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
setToolIntegrationModeRaw(mode);
|
||||
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
|
||||
}
|
||||
break;
|
||||
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||
if (agents != null && !Array.isArray(agents)) {
|
||||
@@ -463,8 +542,17 @@ export function useAIState() {
|
||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
||||
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
|
||||
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
const initialPermMode: AIPermissionMode =
|
||||
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
|
||||
? storedPermMode
|
||||
: 'confirm';
|
||||
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||
const initialToolMode: AIToolIntegrationMode =
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||
? 'skills'
|
||||
: 'mcp';
|
||||
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
|
||||
}, []);
|
||||
|
||||
// ── Session CRUD ──
|
||||
@@ -598,6 +686,61 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -716,6 +859,8 @@ export function useAIState() {
|
||||
// Permission model
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
hostPermissions,
|
||||
setHostPermissions,
|
||||
|
||||
@@ -750,6 +895,7 @@ export function useAIState() {
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -15,15 +15,15 @@ export type SshAgentStatus = {
|
||||
|
||||
export const useApplicationBackend = () => {
|
||||
const openExternal = useCallback(async (url: string) => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.openExternal) {
|
||||
await bridge.openExternal(url);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back below
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.openExternal) {
|
||||
// Bridge resolves on success (either via system browser or in-app
|
||||
// fallback window) and rejects only when both paths fail. Let the
|
||||
// rejection propagate so callers can present a user-facing message.
|
||||
await bridge.openExternal(url);
|
||||
return;
|
||||
}
|
||||
// Fallback for non-Electron environments (tests, dev server, etc.).
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Debounced sync to avoid too frequent API calls
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -16,11 +16,37 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { notify } from '../notification';
|
||||
|
||||
/**
|
||||
* Check whether a sync payload has any meaningful user data. Covers all
|
||||
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
|
||||
* port forwarding rule) are not mistakenly treated as "empty".
|
||||
*/
|
||||
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
|
||||
// Check all synced entity arrays.
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
if (hasEntities) return false;
|
||||
// Also consider settings: if any key has a defined value, the user has
|
||||
// customized something worth preserving.
|
||||
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -32,6 +58,7 @@ interface AutoSyncConfig {
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
@@ -56,10 +83,35 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = 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
|
||||
* cloud vault before the startup pull has run. See #679. */
|
||||
const remoteCheckDoneRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
|
||||
// State for the empty-vault-vs-cloud confirmation dialog (Fix D).
|
||||
// When checkRemoteVersion detects that the local vault is empty but
|
||||
// the cloud has data, it pauses and exposes this state so the root
|
||||
// component can render a confirmation dialog.
|
||||
const [emptyVaultConflict, setEmptyVaultConflict] = useState<{
|
||||
remotePayload: SyncPayload;
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
|
||||
// Listen for SFTP bookmark changes to trigger auto-sync
|
||||
const [bookmarksVersion, setBookmarksVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => setBookmarksVersion((v) => v + 1);
|
||||
window.addEventListener('sftp-bookmarks-changed', handler);
|
||||
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
|
||||
}, []);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
@@ -87,6 +139,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
snippetPackages: config.snippetPackages,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: effectiveKnownHosts,
|
||||
groupConfigs: config.groupConfigs,
|
||||
};
|
||||
}, [
|
||||
config.hosts,
|
||||
@@ -97,6 +150,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.snippetPackages,
|
||||
config.portForwardingRules,
|
||||
config.knownHosts,
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
@@ -162,6 +216,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
@@ -189,7 +253,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw error;
|
||||
}
|
||||
console.error('[AutoSync] Sync failed:', error);
|
||||
toast.error(
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.autoSync.failedTitle'),
|
||||
);
|
||||
@@ -223,18 +287,53 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const localPayload = buildPayload();
|
||||
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
|
||||
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
|
||||
if (userAction === 'restore') {
|
||||
config.onApplyPayload(remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Don't apply remote data.
|
||||
// The next auto-sync will eventually push the empty state if
|
||||
// the user makes another edit.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Don't save base or skip auto-sync — let the data-change effect
|
||||
// naturally trigger an upload of the merged payload (which will
|
||||
// go through syncAllProviders and save base on success).
|
||||
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
// Prevent the data-change effect from immediately re-uploading the
|
||||
// merged payload — the merge already incorporated both sides. The
|
||||
// next deliberate edit by the user will trigger a normal sync.
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
} finally {
|
||||
remoteCheckDoneRef.current = true;
|
||||
}
|
||||
}, [sync, config, buildPayload, t]);
|
||||
|
||||
@@ -244,7 +343,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Don't auto-sync until the startup remote check has completed.
|
||||
// Without this gate, an empty local vault can push to the cloud
|
||||
// before checkRemoteVersion even runs, overwriting a non-empty
|
||||
// remote vault — the exact bug described in #679.
|
||||
if (!remoteCheckDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip initial render
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
@@ -288,7 +395,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
@@ -302,19 +409,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
|
||||
|
||||
// Reset check flag when provider disconnects
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
|
||||
// Guard: resolve only once (prevents double-click from entering an
|
||||
// inconsistent state). The ref is nulled immediately so subsequent
|
||||
// calls are no-ops.
|
||||
const resolve = emptyVaultResolveRef.current;
|
||||
if (!resolve) return;
|
||||
emptyVaultResolveRef.current = null;
|
||||
resolve(action);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
syncNow,
|
||||
buildPayload,
|
||||
isSyncing: sync.isSyncing,
|
||||
isConnected: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
emptyVaultConflict,
|
||||
resolveEmptyVaultConflict,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
type SecurityState,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import {
|
||||
CloudSyncManager,
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
@@ -82,14 +81,30 @@ export interface CloudSyncHook {
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
cancelOAuthConnect: () => void;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
|
||||
// Gist Revision History
|
||||
getGistRevisionHistory: () => Promise<Array<{ version: string; date: Date }>>;
|
||||
downloadGistRevision: (sha: string) => Promise<{
|
||||
payload: SyncPayload;
|
||||
meta: import('../../domain/sync').SyncFileMeta;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null>;
|
||||
|
||||
// Settings
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
@@ -103,12 +118,6 @@ export interface CloudSyncHook {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export interface GitHubAuthState {
|
||||
isAuthenticating: boolean;
|
||||
deviceFlowState: DeviceFlowState | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
@@ -127,17 +136,6 @@ const getSnapshot = (): SyncManagerState => {
|
||||
};
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Force update mechanism to ensure React re-renders
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
// Subscribe to state changes and force update
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribeToStateChanges(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
@@ -273,7 +271,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -281,32 +279,44 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
|
||||
const connectOneDrive = useCallback(async (): Promise<string> => {
|
||||
const result = await manager.startProviderAuth('onedrive');
|
||||
if (result.type !== 'url') {
|
||||
throw new Error('Unexpected auth type');
|
||||
}
|
||||
const data = result.data as { url: string; redirectUri: string };
|
||||
|
||||
|
||||
// Start OAuth callback server in Electron and wait for authorization
|
||||
const bridge = netcattyBridge.get();
|
||||
const startCallback = bridge?.startOAuthCallback;
|
||||
@@ -314,22 +324,33 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// Get state from adapter for CSRF protection
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.url;
|
||||
}, []);
|
||||
|
||||
@@ -345,6 +366,10 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.disconnectProvider(provider);
|
||||
}, []);
|
||||
|
||||
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
|
||||
manager.resetProviderStatus(provider);
|
||||
}, []);
|
||||
|
||||
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
|
||||
await manager.connectConfigProvider('webdav', config);
|
||||
}, []);
|
||||
@@ -353,6 +378,11 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
|
||||
@@ -450,13 +480,19 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
|
||||
resetProviderStatus,
|
||||
|
||||
// Sync Actions
|
||||
syncNow: syncNowWithUnlock,
|
||||
syncToProvider: syncToProviderWithUnlock,
|
||||
downloadFromProvider: downloadFromProviderWithUnlock,
|
||||
resolveConflict: resolveConflictWithUnlock,
|
||||
|
||||
// Gist Revision History (#679)
|
||||
getGistRevisionHistory: manager.getGistRevisionHistory.bind(manager),
|
||||
downloadGistRevision: manager.downloadGistRevision.bind(manager),
|
||||
|
||||
// Settings
|
||||
setAutoSync,
|
||||
@@ -472,60 +508,4 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook for just the security state (lighter weight)
|
||||
*/
|
||||
export const useSecurityState = () => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [securityState, setSecurityState] = useState<SecurityState>(
|
||||
() => manager.getSecurityState()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === 'SECURITY_STATE_CHANGED') {
|
||||
setSecurityState(event.state);
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager]);
|
||||
|
||||
return {
|
||||
securityState,
|
||||
isUnlocked: securityState === 'UNLOCKED',
|
||||
isLocked: securityState === 'LOCKED',
|
||||
hasNoKey: securityState === 'NO_KEY',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for provider status indicators
|
||||
*/
|
||||
export const useProviderStatus = (provider: CloudProvider) => {
|
||||
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
|
||||
const [connection, setConnection] = useState<ProviderConnection>(
|
||||
() => manager.getProviderConnection(provider)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = manager.subscribe(() => {
|
||||
setConnection(manager.getProviderConnection(provider));
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [manager, provider]);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
isConnected: isProviderReadyForSync(connection),
|
||||
isSyncing: connection.status === 'syncing',
|
||||
hasError: connection.status === 'error',
|
||||
dotColor: getSyncDotColor(connection.status),
|
||||
lastSyncFormatted: formatLastSync(connection.lastSync),
|
||||
};
|
||||
};
|
||||
|
||||
export default useCloudSync;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
|
||||
|
||||
export interface HotkeyActions {
|
||||
interface HotkeyActions {
|
||||
// Tab management
|
||||
switchToTab: (tabIndex: number) => void;
|
||||
nextTab: () => void;
|
||||
|
||||
214
application/state/useImmersiveMode.ts
Normal file
214
application/state/useImmersiveMode.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +31,13 @@ export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
@@ -73,6 +77,16 @@ export const usePortForwardingAutoStart = ({
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
}, [groupConfigs]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
|
||||
return applyGroupDefaults(host, defaults);
|
||||
}, []);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -89,11 +103,12 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
}
|
||||
|
||||
const host = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!host) {
|
||||
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!rawHost) {
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
@@ -101,7 +116,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, []);
|
||||
}, [resolveEffectiveHost]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
@@ -146,8 +161,9 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (host) {
|
||||
const rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (rawHost) {
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
@@ -180,5 +196,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
|
||||
};
|
||||
|
||||
@@ -40,25 +40,33 @@ export const useSessionState = () => {
|
||||
|
||||
const createLocalTerminal = useCallback((options?: {
|
||||
shellType?: TerminalSession['shellType'];
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
}) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const localHostId = `local-${sessionId}`;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: localHostId,
|
||||
hostLabel: 'Local Terminal',
|
||||
hostLabel: options?.shellName || 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
protocol: 'local',
|
||||
shellType: options?.shellType,
|
||||
localShell: options?.shell,
|
||||
localShellArgs: options?.shellArgs,
|
||||
localShellName: options?.shellName,
|
||||
localShellIcon: options?.shellIcon,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
const serialHostId = `serial-${sessionId}`;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
@@ -71,6 +79,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: config,
|
||||
charset: options?.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -103,6 +112,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
@@ -120,6 +130,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
@@ -321,6 +332,7 @@ export const useSessionState = () => {
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
charset: host.charset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +346,7 @@ export const useSessionState = () => {
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
charset: host.charset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -445,8 +458,13 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
|
||||
// Add pane to existing workspace
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
@@ -476,13 +494,18 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
|
||||
const hint: SplitHint = {
|
||||
direction,
|
||||
position: direction === 'horizontal' ? 'bottom' : 'right',
|
||||
};
|
||||
|
||||
|
||||
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
|
||||
setWorkspaces(prev => [...prev, newWorkspace]);
|
||||
setActiveTabId(newWorkspace.id);
|
||||
@@ -563,6 +586,7 @@ export const useSessionState = () => {
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting' as const,
|
||||
charset: host.charset,
|
||||
// workspaceId will be set after workspace is created
|
||||
}));
|
||||
|
||||
@@ -649,7 +673,12 @@ export const useSessionState = () => {
|
||||
port: session.port,
|
||||
moshEnabled: session.moshEnabled,
|
||||
shellType: nextShellType,
|
||||
charset: session.charset,
|
||||
serialConfig: session.serialConfig,
|
||||
localShell: session.localShell,
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
@@ -682,9 +711,11 @@ export const useSessionState = () => {
|
||||
...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 => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
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]);
|
||||
|
||||
@@ -698,10 +729,12 @@ export const useSessionState = () => {
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
|
||||
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
|
||||
const draggedIndex = currentOrder.indexOf(draggedId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
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,
|
||||
@@ -30,9 +33,14 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
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,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
@@ -64,6 +72,10 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -120,8 +132,11 @@ const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
// 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.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
@@ -187,6 +202,17 @@ export const useSettingsState = () => {
|
||||
});
|
||||
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
|
||||
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
|
||||
const [followAppTerminalTheme, setFollowAppTerminalThemeState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (stored !== null) return stored === 'true';
|
||||
// First time seeing this key. For genuinely fresh installs (no existing
|
||||
// terminal theme in storage) default ON so the terminal matches the app
|
||||
// theme out of the box. For upgrades from an older version (existing
|
||||
// terminal theme present) default OFF to avoid silently overriding the
|
||||
// user's manual choice.
|
||||
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
return !isUpgrade;
|
||||
});
|
||||
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
|
||||
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
|
||||
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
|
||||
@@ -235,6 +261,26 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
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;
|
||||
});
|
||||
const [showRecentHosts, setShowRecentHostsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
return stored ?? DEFAULT_SHOW_RECENT_HOSTS;
|
||||
});
|
||||
const [showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRootState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
@@ -324,6 +370,24 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
setSftpTransferConcurrencyState(clamped);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
return stored === 'border' ? 'border' : 'dim';
|
||||
});
|
||||
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
|
||||
setWorkspaceFocusStyleState(style);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const syncAppearanceFromStorage = useCallback(() => {
|
||||
const storedTheme = readStoredString(STORAGE_KEY_THEME);
|
||||
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
|
||||
@@ -414,6 +478,18 @@ 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 storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
|
||||
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
setShowRecentHostsState(storedShowRecentHosts ?? DEFAULT_SHOW_RECENT_HOSTS);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -499,6 +575,10 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
|
||||
setTerminalThemeId(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
|
||||
const next = value === true || value === 'true';
|
||||
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
|
||||
setTerminalFontFamilyId(value);
|
||||
}
|
||||
@@ -558,6 +638,17 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((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));
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -591,18 +682,20 @@ export const useSettingsState = () => {
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
@@ -681,6 +774,13 @@ export const useSettingsState = () => {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync follow-app-theme toggle from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
|
||||
const next = e.newValue === 'true';
|
||||
if (next !== s.followAppTerminalTheme) {
|
||||
setFollowAppTerminalThemeState(next);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
@@ -753,6 +853,30 @@ export const useSettingsState = () => {
|
||||
setSftpAutoOpenSidebar(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) {
|
||||
setSftpDefaultViewMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_RECENT_HOSTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showRecentHosts) {
|
||||
setShowRecentHostsState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showOnlyUngroupedHostsInRoot) {
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(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';
|
||||
@@ -767,6 +891,19 @@ export const useSettingsState = () => {
|
||||
setAutoUpdateEnabled(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') {
|
||||
setWorkspaceFocusStyleState(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync transfer concurrency from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
|
||||
const num = Number(e.newValue);
|
||||
if (num >= 1 && num <= 16) {
|
||||
setSftpTransferConcurrencyState(num);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
@@ -779,6 +916,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
|
||||
}, [followAppTerminalTheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -823,6 +966,27 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_RECORDING, isRecording);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowRecentHosts = useCallback((enabled: boolean) => {
|
||||
setShowRecentHostsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_RECENT_HOSTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowOnlyUngroupedHostsInRoot = useCallback((enabled: boolean) => {
|
||||
setShowOnlyUngroupedHostsInRootState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
@@ -874,6 +1038,13 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP default view mode
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
|
||||
}, [sftpDefaultViewMode, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
@@ -1039,12 +1210,21 @@ export const useSettingsState = () => {
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
const currentTerminalTheme = useMemo(() => {
|
||||
// When "Follow Application Theme" is enabled, pick the terminal theme
|
||||
// whose background matches the active UI theme preset.
|
||||
if (followAppTerminalTheme) {
|
||||
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
|
||||
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
|
||||
if (mapped) {
|
||||
const found = TERMINAL_THEMES.find(t => t.id === mapped);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0],
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId]);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
@@ -1053,6 +1233,12 @@ 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 tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
@@ -1073,6 +1259,8 @@ export const useSettingsState = () => {
|
||||
setUiLanguage,
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
@@ -1102,6 +1290,16 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
@@ -1127,6 +1325,9 @@ export const useSettingsState = () => {
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
@@ -1134,8 +1335,9 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
customThemes, workspaceFocusStyle,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Uses a shared state pattern to sync across components
|
||||
*/
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension } from '../../lib/sftpFileUtils';
|
||||
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface FileAssociationEntry {
|
||||
openerType: FileOpenerType;
|
||||
@@ -17,10 +17,12 @@ export interface FileAssociationsMap {
|
||||
[extension: string]: FileAssociationEntry;
|
||||
}
|
||||
|
||||
// Shared state and subscribers for cross-component synchronization
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-extension associations store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
// Use a wrapper object so we can update the reference for useSyncExternalStore
|
||||
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
||||
|
||||
function loadFromStorage(): FileAssociationsMap {
|
||||
@@ -39,7 +41,6 @@ function loadFromStorage(): FileAssociationsMap {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Initialize from storage
|
||||
snapshotRef = { associations: loadFromStorage() };
|
||||
|
||||
function saveToStorage(associations: FileAssociationsMap) {
|
||||
@@ -47,7 +48,6 @@ function saveToStorage(associations: FileAssociationsMap) {
|
||||
}
|
||||
|
||||
function updateAssociations(newAssociations: FileAssociationsMap) {
|
||||
// Create new reference so useSyncExternalStore detects change
|
||||
snapshotRef = { associations: newAssociations };
|
||||
saveToStorage(newAssociations);
|
||||
subscribers.forEach(callback => callback());
|
||||
@@ -62,15 +62,54 @@ function getSnapshot() {
|
||||
return snapshotRef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default opener store (separate from per-extension associations)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultOpenerSubscribers = new Set<() => void>();
|
||||
|
||||
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
|
||||
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
|
||||
};
|
||||
|
||||
function subscribeDefaultOpener(callback: () => void) {
|
||||
defaultOpenerSubscribers.add(callback);
|
||||
return () => defaultOpenerSubscribers.delete(callback);
|
||||
}
|
||||
|
||||
function getDefaultOpenerSnapshot() {
|
||||
return defaultOpenerSnapshot;
|
||||
}
|
||||
|
||||
function updateDefaultOpener(entry: FileAssociationEntry | null) {
|
||||
defaultOpenerSnapshot = { entry };
|
||||
if (entry) {
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
|
||||
} else {
|
||||
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
|
||||
}
|
||||
defaultOpenerSubscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useSftpFileAssociations() {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const associations = snapshot.associations;
|
||||
|
||||
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
|
||||
|
||||
// Listen for storage events from other tabs/windows
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
|
||||
updateAssociations(loadFromStorage());
|
||||
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
|
||||
updateDefaultOpener(
|
||||
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
@@ -78,18 +117,46 @@ export function useSftpFileAssociations() {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the opener entry for a file based on its extension
|
||||
* Get the opener entry for a file based on its extension.
|
||||
* Falls back to the default opener when no per-extension association exists.
|
||||
*/
|
||||
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
|
||||
const ext = getFileExtension(fileName);
|
||||
return associations[ext] || null;
|
||||
}, [associations]);
|
||||
if (associations[ext]) return associations[ext];
|
||||
// Fall back to default opener, but skip built-in editor for binary files
|
||||
const fallback = defaultOpenerState.entry;
|
||||
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
|
||||
return null;
|
||||
}
|
||||
return fallback;
|
||||
}, [associations, defaultOpenerState]);
|
||||
|
||||
/**
|
||||
* Get the default (fallback) opener, if set.
|
||||
*/
|
||||
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
|
||||
return defaultOpenerState.entry;
|
||||
}, [defaultOpenerState]);
|
||||
|
||||
/**
|
||||
* Set the default opener used when no per-extension association exists.
|
||||
*/
|
||||
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
|
||||
updateDefaultOpener({ openerType, systemApp });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove the default opener.
|
||||
*/
|
||||
const removeDefaultOpener = useCallback(() => {
|
||||
updateDefaultOpener(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set the opener type for a specific extension
|
||||
*/
|
||||
const setOpenerForExtension = useCallback((
|
||||
extension: string,
|
||||
extension: string,
|
||||
openerType: FileOpenerType,
|
||||
systemApp?: SystemAppInfo
|
||||
) => {
|
||||
@@ -109,7 +176,7 @@ export function useSftpFileAssociations() {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get all associations as an array
|
||||
* Get all per-extension associations as an array.
|
||||
*/
|
||||
const getAllAssociations = useCallback((): FileAssociation[] => {
|
||||
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
||||
@@ -129,6 +196,9 @@ export function useSftpFileAssociations() {
|
||||
return {
|
||||
associations,
|
||||
getOpenerForFile,
|
||||
getDefaultOpener,
|
||||
setDefaultOpener,
|
||||
removeDefaultOpener,
|
||||
setOpenerForExtension,
|
||||
removeAssociation,
|
||||
getAllAssociations,
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
@@ -110,6 +111,30 @@ export const useSftpState = (
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPaneByConnectionId = useCallback((connectionId: string) => {
|
||||
for (const tab of leftTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) return tab;
|
||||
}
|
||||
for (const tab of rightTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) return tab;
|
||||
}
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
const getTabByConnectionId = useCallback((connectionId: string) => {
|
||||
for (const tab of leftTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) {
|
||||
return { side: "left" as const, tabId: tab.id, pane: tab };
|
||||
}
|
||||
}
|
||||
for (const tab of rightTabsRef.current.tabs) {
|
||||
if (tab.connection?.id === connectionId) {
|
||||
return { side: "right" as const, tabId: tab.id, pane: tab };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
@@ -183,10 +208,14 @@ export const useSftpState = (
|
||||
selectAll,
|
||||
getFilteredFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
hosts,
|
||||
@@ -207,6 +236,7 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs: DIR_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -244,6 +274,7 @@ export const useSftpState = (
|
||||
conflicts,
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -254,8 +285,13 @@ export const useSftpState = (
|
||||
resolveConflict,
|
||||
} = useSftpTransfers({
|
||||
getActivePane,
|
||||
getPaneByConnectionId,
|
||||
getTabByConnectionId,
|
||||
updateTab,
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
@@ -305,15 +341,20 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
@@ -324,6 +365,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -332,6 +374,7 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -352,15 +395,20 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
setShowHiddenFiles,
|
||||
createDirectory,
|
||||
createDirectoryAtPath,
|
||||
createFile,
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
readBinaryFile,
|
||||
@@ -371,6 +419,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
@@ -379,6 +428,7 @@ export const useSftpState = (
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
reportSessionError: handleSessionError,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -402,6 +452,8 @@ export const useSftpState = (
|
||||
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
|
||||
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
|
||||
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
|
||||
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
|
||||
methodsRef.current.clearSelectionsExcept(...args),
|
||||
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
@@ -409,11 +461,17 @@ export const useSftpState = (
|
||||
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
|
||||
methodsRef.current.setShowHiddenFiles(...args),
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
|
||||
methodsRef.current.createDirectoryAtPath(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
|
||||
methodsRef.current.createFileAtPath(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
|
||||
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
|
||||
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
@@ -425,6 +483,7 @@ export const useSftpState = (
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
|
||||
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
|
||||
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
|
||||
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
|
||||
@@ -433,6 +492,7 @@ export const useSftpState = (
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
|
||||
activeFileWatchCountRef,
|
||||
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* Syncs across components in the same window via a custom event,
|
||||
* and across windows via the native storage event.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
@@ -16,9 +18,38 @@ export const useStoredBoolean = (
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
setValue((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
}, [storageKey]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
useEffect(() => {
|
||||
// Sync from other components in the same window
|
||||
const handleCustom = (e: Event) => {
|
||||
const { key, value: newValue } = (e as CustomEvent).detail;
|
||||
if (key === storageKey) setValue(newValue);
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
setValue(stored ?? fallback);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("stored-boolean-change", handleCustom);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, [storageKey, fallback]);
|
||||
|
||||
return [value, setAndPersist] as const;
|
||||
};
|
||||
|
||||
29
application/state/useStoredNumber.ts
Normal file
29
application/state/useStoredNumber.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for reading a number from localStorage with lazy persistence.
|
||||
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
|
||||
* on every state change — call `persist()` explicitly when ready (e.g. on
|
||||
* mouseup after a drag). This avoids flooding localStorage during
|
||||
* high-frequency updates like resize drags.
|
||||
*/
|
||||
export const useStoredNumber = (
|
||||
storageKey: string,
|
||||
fallback: number,
|
||||
clamp?: { min: number; max: number },
|
||||
) => {
|
||||
const [value, setValue] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(storageKey);
|
||||
if (stored === null) return fallback;
|
||||
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
|
||||
return stored;
|
||||
});
|
||||
|
||||
const persist = useCallback(
|
||||
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
return [value, setValue, persist] as const;
|
||||
};
|
||||
@@ -128,6 +128,22 @@ export const useTerminalBackend = () => {
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
}, []);
|
||||
|
||||
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionRemoteInfo) {
|
||||
return { success: false, error: 'getSessionRemoteInfo unavailable' };
|
||||
}
|
||||
return bridge.getSessionRemoteInfo(sessionId);
|
||||
}, []);
|
||||
|
||||
const getSessionDistroInfo = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionDistroInfo) {
|
||||
return { success: false, error: 'getSessionDistroInfo unavailable' };
|
||||
}
|
||||
return bridge.getSessionDistroInfo(sessionId);
|
||||
}, []);
|
||||
|
||||
const getServerStats = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
|
||||
@@ -150,6 +166,8 @@ export const useTerminalBackend = () => {
|
||||
listSerialPorts,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
getSessionDistroInfo,
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// arrives after 8s the duplicate check is avoided.
|
||||
const STARTUP_CHECK_DELAY_MS = 8000;
|
||||
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
|
||||
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
|
||||
|
||||
// Debug logging for update checks (no-op in production)
|
||||
const debugLog = (..._args: unknown[]) => {};
|
||||
@@ -44,6 +43,8 @@ export interface UseUpdateCheckResult {
|
||||
dismissUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
installUpdate: () => void;
|
||||
startDownload: () => void;
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
netcattyBridge.get()?.installUpdate?.();
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
const checkResult = await bridge?.checkForUpdate?.();
|
||||
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
|
||||
if (checkResult.supported === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
if (checkResult.available === false) {
|
||||
openReleasePage();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'downloading',
|
||||
downloadPercent: 0,
|
||||
downloadError: null,
|
||||
}));
|
||||
void bridge?.downloadUpdate?.().then((res) => {
|
||||
if (res && !res.success) {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: res.error || 'Download failed',
|
||||
}));
|
||||
}
|
||||
}).catch(() => {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
autoDownloadStatus: 'error',
|
||||
downloadError: 'Download failed',
|
||||
}));
|
||||
});
|
||||
}, [openReleasePage]);
|
||||
|
||||
// Startup check with delay - runs once on mount
|
||||
useEffect(() => {
|
||||
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
|
||||
@@ -653,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
|
||||
dismissUpdate,
|
||||
openReleasePage,
|
||||
installUpdate,
|
||||
startDownload,
|
||||
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
Host,
|
||||
Identity,
|
||||
KeyCategory,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "../../infrastructure/config/defaultData";
|
||||
import {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_GROUPS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
STORAGE_KEY_IDENTITIES,
|
||||
@@ -30,9 +32,11 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptGroupConfigs,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
@@ -46,6 +50,7 @@ type ExportableVaultData = {
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts?: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
};
|
||||
|
||||
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
|
||||
@@ -107,6 +112,7 @@ export const useVaultState = () => {
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
@@ -114,6 +120,7 @@ export const useVaultState = () => {
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
@@ -122,6 +129,7 @@ export const useVaultState = () => {
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
@@ -176,6 +184,15 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||||
}, []);
|
||||
|
||||
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
|
||||
setGroupConfigs(data);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
encryptGroupConfigs(data).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
@@ -185,6 +202,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
updateGroupConfigs([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -195,6 +213,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -430,6 +449,20 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
@@ -529,6 +562,19 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
setManagedSources(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_GROUP_CONFIGS) {
|
||||
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
|
||||
++groupConfigsWriteVersion.current;
|
||||
const seq = ++groupConfigsReadSeq.current;
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -536,6 +582,20 @@ export const useVaultState = () => {
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const updateHostLastConnected = useCallback((hostId: string) => {
|
||||
setHosts((prev) => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
|
||||
);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateHostDistro = useCallback((hostId: string, distro: string) => {
|
||||
const normalized = normalizeDistroId(distro);
|
||||
setHosts((prev) => {
|
||||
@@ -560,8 +620,9 @@ export const useVaultState = () => {
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
@@ -573,6 +634,7 @@ export const useVaultState = () => {
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
|
||||
},
|
||||
[
|
||||
updateHosts,
|
||||
@@ -582,6 +644,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
updateKnownHosts,
|
||||
updateGroupConfigs,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -604,6 +667,7 @@ export const useVaultState = () => {
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -612,6 +676,7 @@ export const useVaultState = () => {
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
@@ -620,6 +685,7 @@ export const useVaultState = () => {
|
||||
deleteConnectionLog,
|
||||
clearUnsavedConnectionLogs,
|
||||
updateHostDistro,
|
||||
updateHostLastConnected,
|
||||
convertKnownHostToHost,
|
||||
exportData,
|
||||
importDataFromString,
|
||||
|
||||
@@ -8,15 +8,18 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
GroupConfig,
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
PortForwardingRule,
|
||||
SftpBookmark,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
} from '../domain/models';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -37,7 +40,11 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
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,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,6 +60,7 @@ export interface SyncableVaultData {
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
knownHosts: KnownHost[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
@@ -78,6 +86,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -158,6 +168,18 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
||||
|
||||
|
||||
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
|
||||
if (showRecent != null) settings.showRecentHosts = showRecent;
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
@@ -216,6 +238,21 @@ 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));
|
||||
|
||||
// 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(
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -241,6 +278,7 @@ export function buildSyncPayload(
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
@@ -274,6 +312,9 @@ export function applySyncPayload(
|
||||
if (payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
|
||||
@@ -288,6 +329,8 @@ export function applySyncPayload(
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
ChatMessage,
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getAgentModelPresets } from '../infrastructure/ai/types';
|
||||
import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
@@ -38,11 +40,29 @@ import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
@@ -56,6 +76,7 @@ interface AIChatSidePanelProps {
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -73,6 +94,7 @@ interface AIChatSidePanelProps {
|
||||
|
||||
// Agent info
|
||||
defaultAgentId: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
agentModelMap: Record<string, string>;
|
||||
@@ -103,6 +125,7 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
@@ -152,6 +175,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -164,6 +208,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -171,6 +216,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
defaultAgentId,
|
||||
toolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
agentModelMap,
|
||||
@@ -202,6 +248,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
|
||||
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
@@ -227,21 +274,138 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
const session = sessions.find((s) => s.id === activeSessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (activeSessionIdForScope) {
|
||||
const session = sessions.find((s) => s.id === activeSessionIdForScope);
|
||||
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
}, [scopeKey, activeSessionId, sessions]);
|
||||
return historySessions[0] ?? null;
|
||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
|
||||
if (scopeType === 'terminal' && scopeTargetId) {
|
||||
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
|
||||
if (target) {
|
||||
return {
|
||||
...target,
|
||||
source: 'scope-target',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedSessions.length === 1) {
|
||||
return {
|
||||
...connectedSessions[0],
|
||||
source: 'only-connected-in-scope',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't retarget sessions that are actively owned by another terminal
|
||||
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
// Full cleanup of any in-flight work — the session came from a disconnected
|
||||
// terminal, so any active response, pending approvals, or exec is dead.
|
||||
if (streamingSessionIds.has(activeSession.id)) {
|
||||
const controller = abortControllersRef.current.get(activeSession.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(activeSession.id);
|
||||
}
|
||||
setStreamingForScope(activeSession.id, false);
|
||||
clearAllPendingApprovals(activeSession.id);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSession.id);
|
||||
bridge?.aiAcpCancel?.('', activeSession.id);
|
||||
}
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
setCurrentAgentId(activeSession.agentId);
|
||||
}
|
||||
}, [scopeKey, activeSession]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
@@ -294,12 +458,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
[enableAgent, setExternalAgents],
|
||||
);
|
||||
|
||||
// Active session (scoped)
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((s) => s.id === activeSessionId) ?? null,
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
|
||||
// ── Export hook ──
|
||||
@@ -319,10 +477,106 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isCodexManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
|
||||
// so the picker can show just that model instead of the hardcoded ChatGPT
|
||||
// preset list. Probing codex-acp for its full catalog returns the stock
|
||||
// OpenAI models regardless of the active provider, which is misleading.
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void bridge.aiCodexGetIntegration().then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
// Only flip "resolved" to true when the probe confirms this is a
|
||||
// custom-config session; otherwise keep it false so we fall back to
|
||||
// the static CODEX_MODEL_PRESETS.
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isCodexManagedAgent, currentAgentId]);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
if (!isCopilotExternalAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
const knownModelIds = new Set(result.models.map((model) => model.id));
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: result.models ?? [],
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
// When Codex is backed by a ~/.codex/config.toml custom provider, the
|
||||
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
|
||||
// codexCustomConfigResolved (declared above alongside codexConfigModel)
|
||||
// stays false until the integration probe confirms this session is
|
||||
// custom-config, so we don't flash an empty picker while loading.
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
if (hasCodexCustomConfig) {
|
||||
// Config.toml with a pinned model → show just that model.
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
// Config.toml custom provider without a pinned model → codex-acp
|
||||
// uses its provider default. Don't surface the OpenAI presets; they
|
||||
// wouldn't work. Empty list disables the picker.
|
||||
return [];
|
||||
}
|
||||
return runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command);
|
||||
}, [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets, hasCodexCustomConfig, codexConfigModel]);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
@@ -345,15 +599,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
setAgentModel(currentAgentId, modelId);
|
||||
}, [currentAgentId, setAgentModel]);
|
||||
|
||||
// Filtered sessions for history (matching current scope type)
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
[sessions, scopeType, scopeTargetId],
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -------------------------------------------------------------------
|
||||
@@ -420,14 +665,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
|
||||
return activeSessionId;
|
||||
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
|
||||
if (shouldRetargetActiveSession) {
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
} else if (activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
return activeSession.id;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
createSession,
|
||||
currentAgentId,
|
||||
retargetSessionScope,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
shouldRetargetActiveSession,
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
@@ -470,7 +735,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
@@ -490,8 +757,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
@@ -527,8 +796,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -588,7 +858,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<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
|
||||
@@ -747,9 +1017,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
const timeStr = formatRelativeTime(time, t);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
@@ -770,7 +1043,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Github,
|
||||
Key,
|
||||
Loader2,
|
||||
History,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Server,
|
||||
@@ -102,11 +103,14 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
|
||||
if (status === 'connecting') {
|
||||
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
connected: 'bg-green-500',
|
||||
syncing: 'bg-blue-500 animate-pulse',
|
||||
error: 'bg-red-500',
|
||||
connecting: 'bg-yellow-500 animate-pulse',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
@@ -279,8 +283,10 @@ interface ProviderCardProps {
|
||||
disabled?: boolean; // Disable connect button when another provider is connected
|
||||
onEdit?: () => void;
|
||||
onConnect: () => void;
|
||||
onCancelConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSync: () => void;
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
@@ -296,8 +302,10 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
disabled,
|
||||
onEdit,
|
||||
onConnect,
|
||||
onCancelConnect,
|
||||
onDisconnect,
|
||||
onSync,
|
||||
extraActions,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const formatLastSync = (timestamp?: number): string => {
|
||||
@@ -367,7 +375,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -388,6 +398,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
)}
|
||||
{t('cloudSync.provider.sync')}
|
||||
</Button>
|
||||
{extraActions}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -408,6 +419,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<CloudOff size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting && onCancelConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -611,7 +632,7 @@ interface SyncDashboardProps {
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
onClearLocalData,
|
||||
@@ -698,6 +719,20 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Conflict modal
|
||||
const [showConflictModal, setShowConflictModal] = useState(false);
|
||||
|
||||
// Gist revision history (#679)
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [historyRevisions, setHistoryRevisions] = useState<Array<{ version: string; date: Date }>>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyPreview, setHistoryPreview] = useState<{
|
||||
sha: string;
|
||||
payload: SyncPayload;
|
||||
preview: { hostCount: number; keyCount: number; snippetCount: number; identityCount: number; portForwardingRuleCount: number };
|
||||
deviceName?: string;
|
||||
version?: number;
|
||||
} | null>(null);
|
||||
const [historyPreviewLoading, setHistoryPreviewLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
// Change master key dialog
|
||||
const [showChangeKeyDialog, setShowChangeKeyDialog] = useState(false);
|
||||
const [currentMasterKey, setCurrentMasterKey] = useState('');
|
||||
@@ -800,6 +835,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
toast.success(t('cloudSync.connect.github.success'));
|
||||
} catch (error) {
|
||||
setIsPollingGitHub(false);
|
||||
setShowGitHubModal(false);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('github');
|
||||
const message = getNetworkErrorMessage(error, t('common.unknownError'));
|
||||
toast.error(message, t('cloudSync.connect.github.failedTitle'));
|
||||
}
|
||||
@@ -813,10 +851,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.google.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('google');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -828,10 +869,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Note: Auth flow is handled automatically by oauthBridge
|
||||
toast.info(t('cloudSync.connect.browserContinue'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.connect.onedrive.failedTitle'),
|
||||
);
|
||||
// Reset provider status so button is clickable again (without tearing down existing connections)
|
||||
sync.resetProviderStatus('onedrive');
|
||||
const msg = error instanceof Error ? error.message : t('common.unknownError');
|
||||
// Don't show toast for user-initiated cancellation (popup closed)
|
||||
if (!msg.includes('cancelled')) {
|
||||
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1004,6 +1048,60 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// -- Gist revision history handlers --
|
||||
|
||||
const handleOpenHistory = async () => {
|
||||
setShowHistoryModal(true);
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
setHistoryPreview(null);
|
||||
setHistoryRevisions([]);
|
||||
try {
|
||||
const revisions = await sync.getGistRevisionHistory();
|
||||
setHistoryRevisions(revisions);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : t('common.unknownError'));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewRevision = async (sha: string) => {
|
||||
setHistoryPreviewLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const result = await sync.downloadGistRevision(sha);
|
||||
if (result) {
|
||||
setHistoryPreview({
|
||||
sha,
|
||||
payload: result.payload,
|
||||
preview: result.preview,
|
||||
deviceName: result.meta.deviceName,
|
||||
version: result.meta.version,
|
||||
});
|
||||
} else {
|
||||
setHistoryError(t('cloudSync.revisionHistory.revisionNotFound'));
|
||||
}
|
||||
} catch {
|
||||
// Decrypt failures can manifest as various error types:
|
||||
// "Decryption failed", OperationError, "unable to authenticate
|
||||
// data", AES-GCM tag mismatch, etc. Show the friendly message
|
||||
// for any error originating from the decrypt step; network
|
||||
// errors would have been caught by the fetch layer already.
|
||||
setHistoryError(t('cloudSync.revisionHistory.decryptFailed'));
|
||||
} finally {
|
||||
setHistoryPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreRevision = () => {
|
||||
if (!historyPreview) return;
|
||||
onApplyPayload(historyPreview.payload);
|
||||
toast.success(t('cloudSync.revisionHistory.restored'));
|
||||
setShowHistoryModal(false);
|
||||
setHistoryPreview(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with status */}
|
||||
@@ -1065,6 +1163,14 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onConnect={handleConnectGitHub}
|
||||
onDisconnect={() => sync.disconnectProvider('github')}
|
||||
onSync={() => handleSync('github')}
|
||||
extraActions={
|
||||
isProviderReadyForSync(sync.providers.github) ? (
|
||||
<Button size="sm" variant="ghost" onClick={handleOpenHistory} className="gap-1">
|
||||
<History size={14} />
|
||||
{t('cloudSync.revisionHistory.viewButton')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ProviderCard
|
||||
@@ -1079,6 +1185,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
/>
|
||||
@@ -1095,6 +1202,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
/>
|
||||
@@ -1250,6 +1358,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onClose={() => {
|
||||
setShowGitHubModal(false);
|
||||
setIsPollingGitHub(false);
|
||||
// Reset provider status so button is clickable again.
|
||||
// The background polling will continue until expiry but is harmless.
|
||||
sync.resetProviderStatus('github');
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1260,6 +1371,113 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onClose={() => setShowConflictModal(false)}
|
||||
/>
|
||||
|
||||
{/* Gist Revision History Modal (#679) */}
|
||||
<Dialog open={showHistoryModal} onOpenChange={setShowHistoryModal}>
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[80vh] overflow-hidden flex flex-col z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History size={18} />
|
||||
{t('cloudSync.revisionHistory.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('cloudSync.revisionHistory.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{historyError && (
|
||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-500">
|
||||
{historyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : historyPreview ? (
|
||||
// Preview of a selected revision
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="text-sm font-medium">{t('cloudSync.revisionHistory.revisionPreview')}</div>
|
||||
{historyPreview.deviceName && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.revisionHistory.device')}: {historyPreview.deviceName}
|
||||
{historyPreview.version != null && ` · v${historyPreview.version}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.hosts')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.hostCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.keys')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.keyCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.snippets')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.snippetCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-2 py-1 bg-muted/30 rounded">
|
||||
<span className="text-muted-foreground">{t('cloudSync.revisionHistory.identities')}</span>
|
||||
<span className="font-medium">{historyPreview.preview.identityCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setHistoryPreview(null)}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button onClick={handleRestoreRevision} className="gap-1">
|
||||
<Download size={14} />
|
||||
{t('cloudSync.revisionHistory.restoreButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
// Revision list
|
||||
<div className="overflow-y-auto flex-1 min-h-0 -mx-1">
|
||||
{historyRevisions.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
{t('cloudSync.revisionHistory.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 px-1">
|
||||
{historyRevisions.map((rev, index) => (
|
||||
<button
|
||||
key={rev.version}
|
||||
onClick={() => handlePreviewRevision(rev.version)}
|
||||
disabled={historyPreviewLoading}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between p-2.5 rounded-lg text-left text-sm transition-colors",
|
||||
"hover:bg-accent border border-transparent hover:border-border",
|
||||
index === 0 && "bg-primary/5 border-primary/20",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{index === 0 ? t('cloudSync.revisionHistory.current') : `${t('cloudSync.revisionHistory.revision')} #${historyRevisions.length - index}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{rev.date.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{rev.version.slice(0, 7)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyPreviewLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center rounded-lg">
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showWebdavDialog} onOpenChange={setShowWebdavDialog}>
|
||||
<DialogContent className="sm:max-w-[460px] max-h-[80vh] overflow-y-auto z-[70]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -22,6 +22,16 @@ export const DISTRO_LOGOS: Record<string, string> = {
|
||||
macos: "/distro/macos.svg",
|
||||
windows: "/distro/windows.svg",
|
||||
linux: "/distro/linux.svg",
|
||||
// Network device vendors — auto-detected from the SSH server
|
||||
// identification string (see domain/host.ts `detectVendorFromSshVersion`).
|
||||
cisco: "/distro/cisco.svg",
|
||||
juniper: "/distro/juniper.svg",
|
||||
huawei: "/distro/huawei.svg",
|
||||
hpe: "/distro/hpe.svg",
|
||||
mikrotik: "/distro/mikrotik.svg",
|
||||
fortinet: "/distro/fortinet.svg",
|
||||
paloalto: "/distro/paloalto.svg",
|
||||
zyxel: "/distro/zyxel.svg",
|
||||
};
|
||||
|
||||
export const DISTRO_COLORS: Record<string, string> = {
|
||||
@@ -42,6 +52,15 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
macos: "bg-[#333333]",
|
||||
windows: "bg-[#0078D4]",
|
||||
linux: "bg-[#333333]",
|
||||
// Network device vendor brand colors
|
||||
cisco: "bg-[#1BA0D7]",
|
||||
juniper: "bg-[#0A6EB4]",
|
||||
huawei: "bg-[#CF0A2C]",
|
||||
hpe: "bg-[#01A982]",
|
||||
mikrotik: "bg-[#293239]",
|
||||
fortinet: "bg-[#EE3124]",
|
||||
paloalto: "bg-[#FA582D]",
|
||||
zyxel: "bg-[#00497A]",
|
||||
default: "bg-slate-600",
|
||||
};
|
||||
|
||||
@@ -65,8 +84,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded-md",
|
||||
md: "h-11 w-11 rounded-xl",
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
@@ -98,7 +117,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center border border-border/40 overflow-hidden",
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
|
||||
1167
components/GroupDetailsPanel.tsx
Normal file
1167
components/GroupDetailsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,13 +25,18 @@ import {
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
Router,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import {
|
||||
getEffectiveHostDistro,
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
} from "../domain/host";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
@@ -43,7 +48,7 @@ import {
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -82,7 +88,10 @@ type SubPanel =
|
||||
| "theme-select"
|
||||
| "telnet-theme-select";
|
||||
|
||||
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
|
||||
const LINUX_DISTRO_OPTION_IDS = [
|
||||
...LINUX_DISTRO_OPTIONS,
|
||||
...NETWORK_DEVICE_OPTIONS,
|
||||
];
|
||||
|
||||
interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
@@ -93,10 +102,15 @@ interface HostDetailsPanelProps {
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
terminalThemeId: string;
|
||||
terminalFontSize: number;
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
|
||||
onCreateTag?: (tag: string) => void; // Callback to create a new tag
|
||||
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
|
||||
groupConfigs?: GroupConfig[];
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
@@ -108,14 +122,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
terminalThemeId,
|
||||
terminalFontSize,
|
||||
onSave,
|
||||
onCancel,
|
||||
onCreateGroup,
|
||||
onCreateTag,
|
||||
groupDefaults,
|
||||
groupConfigs = [],
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const { terminalThemeId, terminalFontSize } = useSettingsState();
|
||||
const [form, setForm] = useState<Host>(
|
||||
() =>
|
||||
initialData ||
|
||||
@@ -123,13 +141,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
id: crypto.randomUUID(),
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
port: groupDefaults?.port ? undefined : 22,
|
||||
username: groupDefaults?.username ? "" : "root",
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
authMethod: "password",
|
||||
charset: "UTF-8",
|
||||
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
group: defaultGroup || undefined, // Pre-fill with current navigation group
|
||||
@@ -196,9 +214,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const effectiveGroupDefaults = useMemo(() => {
|
||||
const currentGroupPath = form.group || defaultGroup;
|
||||
if (currentGroupPath && groupConfigs.length > 0) {
|
||||
return resolveGroupDefaults(currentGroupPath, groupConfigs);
|
||||
}
|
||||
return groupDefaults;
|
||||
}, [defaultGroup, form.group, groupConfigs, groupDefaults]);
|
||||
|
||||
const effectiveThemeId = useMemo(
|
||||
() => resolveHostTerminalThemeId(form, terminalThemeId),
|
||||
[form, terminalThemeId],
|
||||
() => resolveHostTerminalThemeId(form, resolveGroupTerminalThemeId(effectiveGroupDefaults, terminalThemeId)),
|
||||
[effectiveGroupDefaults, form, terminalThemeId],
|
||||
);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => resolveHostTerminalFontSize(form, terminalFontSize),
|
||||
@@ -279,12 +305,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const removeHostFromChain = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
hostChain: {
|
||||
hostIds: (prev.hostChain?.hostIds || []).filter((_, i) => i !== index),
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
|
||||
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
|
||||
});
|
||||
};
|
||||
|
||||
const clearHostChain = useCallback(() => {
|
||||
@@ -310,12 +334,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
};
|
||||
|
||||
const removeEnvVar = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
environmentVariables: (prev.environmentVariables || []).filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
|
||||
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -360,7 +382,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
port: form.port || 22,
|
||||
port: form.port ?? (groupDefaults?.port ? undefined : 22),
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
@@ -501,6 +523,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onSave={handleCreateGroup}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -513,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -530,6 +554,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClearChain={clearHostChain}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -558,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -569,12 +595,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
open={true}
|
||||
selectedThemeId={effectiveThemeId}
|
||||
onSelect={(themeId) => {
|
||||
if (themeId === effectiveThemeId && !hasEffectiveThemeOverride) {
|
||||
setActiveSubPanel("none");
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
|
||||
setActiveSubPanel("none");
|
||||
}}
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -613,6 +644,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onClose={onCancel}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
showBackButton={true}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -622,6 +654,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
width="w-[420px]"
|
||||
layout={layout}
|
||||
dataSection="host-details-panel"
|
||||
title={
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
@@ -735,7 +770,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
@@ -748,8 +783,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => update("port", Number(e.target.value))}
|
||||
value={form.port ?? ""}
|
||||
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
|
||||
className="h-8 flex-1 min-w-0 text-center"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -801,7 +837,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
if (!hasIdentities) {
|
||||
return (
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => update("username", e.target.value)}
|
||||
className="h-10"
|
||||
@@ -820,7 +856,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t("hostDetails.username.placeholder")}
|
||||
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
@@ -980,9 +1016,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
@@ -1175,10 +1211,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
@@ -1259,18 +1295,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={form.sftpEncoding || "auto"}
|
||||
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-8 w-28">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1282,6 +1320,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
@@ -1290,113 +1433,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.os === "linux" && (
|
||||
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Globe size={14} className="mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
|
||||
<Select
|
||||
value={form.distroMode || "auto"}
|
||||
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
|
||||
<span className="truncate whitespace-nowrap pr-2 text-left">
|
||||
{form.distroMode === "manual"
|
||||
? t("hostDetails.distro.mode.manual")
|
||||
: t("hostDetails.distro.mode.auto")}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
|
||||
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.distroMode === "manual" ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
|
||||
<Select
|
||||
value={form.manualDistro}
|
||||
onValueChange={(val) => update("manualDistro", val)}
|
||||
>
|
||||
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
|
||||
{(() => {
|
||||
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
|
||||
return selectedOption ? (
|
||||
<div className="flex min-w-0 items-center gap-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
selectedOption.bgClass,
|
||||
)}
|
||||
>
|
||||
{selectedOption.icon ? (
|
||||
<img
|
||||
src={selectedOption.icon}
|
||||
alt={selectedOption.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
|
||||
);
|
||||
})()}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[14rem]">
|
||||
{distroOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
|
||||
option.bgClass,
|
||||
)}
|
||||
>
|
||||
{option.icon ? (
|
||||
<img
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className="h-3 w-3 object-contain invert brightness-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-white/70" />
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
|
||||
{effectiveFormDistro
|
||||
? getDistroOptionLabel(effectiveFormDistro)
|
||||
: t("hostDetails.distro.unknown")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Theme Selection */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1513,7 +1549,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<ToggleRow
|
||||
label="Mosh"
|
||||
enabled={!!form.moshEnabled}
|
||||
onToggle={() => update("moshEnabled", !form.moshEnabled)}
|
||||
onToggle={() => {
|
||||
const enabling = !form.moshEnabled;
|
||||
if (enabling && form.deviceType === 'network') {
|
||||
// Network device mode is incompatible with Mosh — clear it
|
||||
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
|
||||
} else {
|
||||
update("moshEnabled", enabling);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1546,6 +1590,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
|
||||
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Router size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.deviceType")}
|
||||
enabled={form.deviceType === 'network'}
|
||||
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.deviceType.desc")}
|
||||
</p>
|
||||
{form.deviceType === 'network' && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.deviceType.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1568,6 +1638,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
@@ -1717,7 +1798,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setForm((prev) => ({ ...prev, environmentVariables: [] }));
|
||||
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
@@ -1740,7 +1821,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
@@ -1804,7 +1885,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
{/* Telnet Charset */}
|
||||
<Input
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
|
||||
value={form.charset || "UTF-8"}
|
||||
onChange={(e) => update("charset", e.target.value)}
|
||||
className="h-10"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
@@ -32,9 +32,12 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -56,9 +59,12 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
getDropTargetClasses?: (target: string) => string;
|
||||
setDragOverDropTarget?: (target: string | null) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,9 +87,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -137,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
<div
|
||||
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
|
||||
@@ -144,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(node.path);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
const nextTarget = e.relatedTarget;
|
||||
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setDragOverDropTarget?.(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
@@ -176,6 +195,15 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
{hostsCountInNode}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
@@ -226,9 +254,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -244,6 +275,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
@@ -264,6 +296,7 @@ interface HostTreeItemProps {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
@@ -278,6 +311,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
@@ -348,6 +382,15 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -364,7 +407,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
@@ -396,12 +439,15 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
getDropTargetClasses,
|
||||
setDragOverDropTarget,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
@@ -522,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
getDropTargetClasses={getDropTargetClasses}
|
||||
setDragOverDropTarget={setDragOverDropTarget}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -552,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* This modal displays prompts from the SSH server and collects user responses.
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
|
||||
|
||||
export interface KeyboardInteractiveRequest {
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
name: string;
|
||||
instructions: string;
|
||||
prompts: KeyboardInteractivePrompt[];
|
||||
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
}
|
||||
|
||||
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
|
||||
if (prompt.echo) return false;
|
||||
const lower = prompt.prompt.toLowerCase();
|
||||
if (!lower.includes("password")) return false;
|
||||
// Exclude OTP / one-time password / verification code prompts
|
||||
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[]) => void;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [savePassword, setSavePassword] = useState(false);
|
||||
|
||||
// Index of the first password prompt (if any)
|
||||
const passwordPromptIndex = useMemo(() => {
|
||||
if (!request) return -1;
|
||||
return request.prompts.findIndex(p => isAPasswordPrompt(p));
|
||||
}, [request]);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setResponses(request.prompts.map(() => ""));
|
||||
const initial = request.prompts.map(() => "");
|
||||
// Auto-fill saved password into the password prompt
|
||||
if (request.savedPassword && passwordPromptIndex >= 0) {
|
||||
initial[passwordPromptIndex] = request.savedPassword;
|
||||
}
|
||||
setResponses(initial);
|
||||
setShowPasswords(request.prompts.map(() => false));
|
||||
setIsSubmitting(false);
|
||||
setSavePassword(false);
|
||||
}
|
||||
}, [request]);
|
||||
}, [request, passwordPromptIndex]);
|
||||
|
||||
const handleResponseChange = useCallback((index: number, value: string) => {
|
||||
setResponses((prev) => {
|
||||
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, responses);
|
||||
}, [request, responses, onSubmit, isSubmitting]);
|
||||
const passwordToSave = savePassword && passwordPromptIndex >= 0
|
||||
? responses[passwordPromptIndex]
|
||||
: undefined;
|
||||
onSubmit(request.requestId, responses, passwordToSave);
|
||||
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Use saved password button - shown below input, right-aligned */}
|
||||
{isPassword && request.savedPassword && !responses[index] && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
onClick={() => handleResponseChange(index, request.savedPassword!)}
|
||||
{/* Save password checkbox - shown only for the first password prompt */}
|
||||
{index === passwordPromptIndex && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={savePassword}
|
||||
onChange={(e) => setSavePassword(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{t("keyboard.interactive.useSavedPassword")}</span>
|
||||
</button>
|
||||
</div>
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keyboard.interactive.savePassword")}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -515,12 +515,12 @@ echo $3 >> "$FILE"`);
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto transition-all duration-200",
|
||||
"flex-1 flex flex-col min-h-0 transition-all duration-200",
|
||||
panel.type !== "closed" && "mr-[380px]",
|
||||
)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* KEY button with split interaction: left=switch view, right=dropdown */}
|
||||
@@ -684,8 +684,10 @@ echo $3 >> "$FILE"`);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 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">
|
||||
{t("keychain.section.keys")}
|
||||
@@ -817,6 +819,7 @@ echo $3 >> "$FILE"`);
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide-out Panel */}
|
||||
|
||||
@@ -36,19 +36,35 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const explicitThemeId = useMemo(() => {
|
||||
if (!log.themeId) return undefined;
|
||||
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|
||||
|| customThemes.some((theme) => theme.id === log.themeId);
|
||||
return exists ? log.themeId : undefined;
|
||||
}, [customThemes, log.themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (log.themeId && !explicitThemeId) {
|
||||
onUpdateLog(log.id, { themeId: undefined });
|
||||
}
|
||||
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (log.themeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|
||||
|| customThemes.find(t => t.id === log.themeId)
|
||||
if (previewTheme) {
|
||||
return previewTheme;
|
||||
}
|
||||
if (explicitThemeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|
||||
|| customThemes.find(t => t.id === explicitThemeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [log.themeId, defaultTerminalTheme, customThemes]);
|
||||
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
@@ -69,6 +85,12 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
onUpdateLog(log.id, { themeId });
|
||||
}, [log.id, onUpdateLog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!themeModalOpen) {
|
||||
setPreviewTheme(null);
|
||||
}
|
||||
}, [themeModalOpen]);
|
||||
|
||||
// Handle font size change
|
||||
const handleFontSizeChange = useCallback((fontSize: number) => {
|
||||
onUpdateLog(log.id, { fontSize });
|
||||
@@ -295,10 +317,13 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
<ThemeCustomizeModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
currentThemeId={currentTheme.id}
|
||||
currentThemeId={explicitThemeId}
|
||||
displayThemeId={currentTheme.id}
|
||||
currentFontSize={currentFontSize}
|
||||
onThemeChange={handleThemeChange}
|
||||
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
|
||||
onFontSizeChange={handleFontSizeChange}
|
||||
onPreviewThemeChange={setPreviewTheme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,12 +14,14 @@ import React, { useCallback, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
GroupConfig,
|
||||
Host,
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
SSHKey,
|
||||
} from "../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -66,6 +68,7 @@ interface PortForwardingProps {
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -77,6 +80,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
groupConfigs = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -113,8 +117,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
// Start a port forwarding tunnel
|
||||
const handleStartTunnel = useCallback(
|
||||
async (rule: PortForwardingRule) => {
|
||||
const _host = hosts.find((h) => h.id === rule.hostId);
|
||||
if (!_host) {
|
||||
const _rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (!_rawHost) {
|
||||
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
|
||||
toast.error(
|
||||
t("pf.error.hostNotFound"),
|
||||
@@ -123,6 +127,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const _host = _rawHost.group
|
||||
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
|
||||
: _rawHost;
|
||||
|
||||
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
||||
let errorShown = false;
|
||||
|
||||
@@ -161,7 +169,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
185
components/QuickAddSnippetDialog.tsx
Normal file
185
components/QuickAddSnippetDialog.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* QuickAddSnippetDialog — lightweight "new snippet" modal mounted at the
|
||||
* App root and triggered by the `netcatty:snippets:add` window event.
|
||||
*
|
||||
* Intentionally minimal: label + command + package only. Advanced fields
|
||||
* (target hosts, shortkey, tags) can be set later via the full Snippets
|
||||
* manager. This keeps the user in their terminal context instead of
|
||||
* navigating to the Vault view just to add a command.
|
||||
*/
|
||||
|
||||
import { Package } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { Snippet } from '../domain/models';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
|
||||
export interface QuickAddSnippetDialogProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
onCreateSnippet: (snippet: Snippet) => void;
|
||||
onCreatePackage?: (packagePath: string) => void;
|
||||
}
|
||||
|
||||
export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
onCreateSnippet,
|
||||
onCreatePackage,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Listen for the global "add snippet" request dispatched by the
|
||||
// terminal-side ScriptsSidePanel + button. We reset form state on
|
||||
// every open so stale input from a previous cancel does not leak.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
return () => window.removeEventListener('netcatty:snippets:add', handler);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the label input once the dialog renders, so the user can
|
||||
// start typing immediately after clicking the + button.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const id = window.setTimeout(() => labelInputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Derive combobox options from the union of existing packages (from
|
||||
// props) and any package path referenced by an existing snippet, so
|
||||
// the user can reuse anything they see in the main snippets view.
|
||||
const packageOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of packages) {
|
||||
if (p) set.add(p);
|
||||
}
|
||||
for (const s of snippets) {
|
||||
if (s.package) set.add(s.package);
|
||||
}
|
||||
return Array.from(set).sort().map((value) => ({ value, label: value }));
|
||||
}, [packages, snippets]);
|
||||
|
||||
const canSave = label.trim().length > 0 && command.trim().length > 0;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSave) return;
|
||||
const trimmedPackage = packagePath.trim();
|
||||
// If the user typed a brand new package name, surface it to the parent
|
||||
// so it can be added to the user's package list alongside the snippet.
|
||||
if (trimmedPackage && !packages.includes(trimmedPackage)) {
|
||||
onCreatePackage?.(trimmedPackage);
|
||||
}
|
||||
onCreateSnippet({
|
||||
id: crypto.randomUUID(),
|
||||
label: label.trim(),
|
||||
command, // preserve whitespace in multi-line commands
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
});
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, label, command]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// Cmd/Ctrl+Enter from anywhere in the dialog saves the snippet.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canSave) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[canSave, handleSave],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md" onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('snippets.panel.newTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('snippets.empty.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-label" className="text-xs">
|
||||
{t('snippets.field.description')}
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-add-snippet-label"
|
||||
ref={labelInputRef}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('snippets.field.descriptionPlaceholder')}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-add-snippet-command" className="text-xs">
|
||||
{t('snippets.field.scriptRequired')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="quick-add-snippet-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="min-h-[120px] font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1.5">
|
||||
<Package size={12} /> {t('snippets.field.package')}
|
||||
</Label>
|
||||
<Combobox
|
||||
value={packagePath}
|
||||
onValueChange={setPackagePath}
|
||||
options={packageOptions}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate
|
||||
onCreateNew={setPackagePath}
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickAddSnippetDialog;
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
Shield,
|
||||
FolderLock,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
@@ -10,9 +10,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
|
||||
type QuickSwitcherItem = {
|
||||
type: "host" | "tab" | "workspace" | "action";
|
||||
type: "host" | "tab" | "workspace" | "action" | "shell";
|
||||
id: string;
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
@@ -66,9 +67,10 @@ interface QuickSwitcherProps {
|
||||
onSelect: (host: Host) => void;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -83,8 +85,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
keyBindings,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const filteredShells = useMemo(() => {
|
||||
const list = !query.trim()
|
||||
? discoveredShells
|
||||
: discoveredShells.filter(
|
||||
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
// Default shell first
|
||||
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
||||
}, [discoveredShells, query]);
|
||||
|
||||
// Get hotkey display strings
|
||||
const getHotkeyLabel = useCallback((actionId: string) => {
|
||||
const binding = keyBindings?.find(k => k.id === actionId);
|
||||
@@ -98,13 +113,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
|
||||
setSelectedIndex(0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle clicks outside the container
|
||||
@@ -144,20 +163,30 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
);
|
||||
// Tabs (built-in + sessions + workspaces)
|
||||
items.push({ type: "tab", id: "vault" });
|
||||
items.push({ type: "tab", id: "sftp" });
|
||||
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
|
||||
orphanSessions.forEach((s) =>
|
||||
items.push({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
workspaces.forEach((w) =>
|
||||
items.push({ type: "workspace", id: w.id, data: w }),
|
||||
);
|
||||
// Quick connect actions
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
// Local shells (or fallback action if discovery not ready)
|
||||
if (filteredShells.length > 0) {
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
} else {
|
||||
items.push({ type: "action", id: "local-terminal" });
|
||||
}
|
||||
} else {
|
||||
// Recent connections only
|
||||
results.forEach((host) =>
|
||||
items.push({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
// Also include matching shells in search results
|
||||
filteredShells.forEach((shell) =>
|
||||
items.push({ type: "shell", id: shell.id }),
|
||||
);
|
||||
}
|
||||
|
||||
// Build index map for O(1) lookup
|
||||
@@ -167,7 +196,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
|
||||
return { flatItems: items, itemIndexMap: indexMap };
|
||||
}, [showCategorized, results, orphanSessions, workspaces]);
|
||||
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
|
||||
|
||||
// O(1) index lookup
|
||||
const getItemIndex = useCallback((type: string, id: string) => {
|
||||
@@ -206,6 +235,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
case "shell": {
|
||||
const shell = discoveredShells.find(s => s.id === item.id);
|
||||
if (shell && onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,12 +319,12 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
<FolderLock size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
@@ -365,21 +402,60 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
{/* Local Shells section */}
|
||||
{/* Local Shells or fallback Local Terminal */}
|
||||
{filteredShells.length > 0 ? (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
{filteredShells.map((shell) => {
|
||||
const idx = getItemIndex("shell", shell.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={shell.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (onCreateLocalTerminal) {
|
||||
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<img
|
||||
src={getShellIconPath(shell.icon)}
|
||||
alt={shell.name}
|
||||
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">{shell.name}</span>
|
||||
{shell.isDefault && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{t("qs.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
) : onCreateLocalTerminal && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.localShells")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
||||
getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
@@ -393,10 +469,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Clicking a snippet executes it in the focused terminal session.
|
||||
*/
|
||||
|
||||
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
|
||||
import { ChevronRight, Package, Plus, Search, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -119,15 +119,25 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onSnippetClick(command, noAutoRun);
|
||||
}, [onSnippetClick]);
|
||||
|
||||
const handleAddSnippet = useCallback(() => {
|
||||
// Let the App shell listen and navigate to the Snippets section with
|
||||
// the "add" panel pre-opened, so the user does not have to leave the
|
||||
// terminal to jump back and click "New Snippet".
|
||||
window.dispatchEvent(new CustomEvent('netcatty:snippets:add'));
|
||||
}, []);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="snippets-panel"
|
||||
>
|
||||
{/* Search + Add */}
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
@@ -136,6 +146,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
title={t('snippets.action.newSnippet')}
|
||||
aria-label={t('snippets.action.newSnippet')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -14,11 +12,18 @@ import { Host, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
interface SelectHostPanelProps {
|
||||
hosts: Host[];
|
||||
@@ -38,6 +43,7 @@ interface SelectHostPanelProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
@@ -57,6 +63,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
layout = "overlay",
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const panelTitle = title ?? t("selectHost.title");
|
||||
@@ -198,35 +205,21 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onBack}
|
||||
title={panelTitle}
|
||||
subtitle={subtitle}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
|
||||
layout === "overlay" && "z-40",
|
||||
showNewHostPanel && "overflow-visible",
|
||||
className,
|
||||
)}
|
||||
layout={layout}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{panelTitle}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
|
||||
@@ -270,8 +263,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<ScrollArea className="flex-1 min-w-0">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Breadcrumbs */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -301,20 +294,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
)}
|
||||
{groupsWithCounts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
|
||||
<div className="space-y-1">
|
||||
{groupsWithCounts.map((group) => (
|
||||
<div
|
||||
key={group.path}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => setCurrentPath(group.path)}
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<LayoutGrid size={18} />
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
|
||||
<LayoutGrid size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[13px] font-medium truncate">{group.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: group.count })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,18 +320,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
{/* Hosts Section */}
|
||||
{filteredHosts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredHosts.map((host) => {
|
||||
const isSelected = selectedHostIds.includes(host.id);
|
||||
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-muted border border-border"
|
||||
? "bg-muted"
|
||||
: "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -346,16 +340,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-10 w-10"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[13px] font-medium truncate">
|
||||
{host.label}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{host.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{connectionStr}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{connectionStr}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary" />
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -374,7 +384,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border/60">
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
@@ -412,7 +422,8 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AsidePanel>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ interface SerialPort {
|
||||
interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
const [charset, setCharset] = useState('UTF-8');
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
charset,
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onConnect(config, { charset });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Usb size={18} />
|
||||
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
|
||||
{/* Serial Port Selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
@@ -66,6 +69,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
@@ -107,6 +111,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
charset,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
@@ -162,6 +167,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
layout={layout}
|
||||
dataSection="serial-host-details-panel"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
@@ -392,6 +399,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charset */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
||||
{t('serial.field.charset')}
|
||||
</Label>
|
||||
<Input
|
||||
id="serial-charset"
|
||||
placeholder={t("hostDetails.charset.placeholder")}
|
||||
value={charset}
|
||||
onChange={(e) => setCharset(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
|
||||
checkNow: UseUpdateCheckResult['checkNow'];
|
||||
openReleasePage: UseUpdateCheckResult['openReleasePage'];
|
||||
installUpdate: UseUpdateCheckResult['installUpdate'];
|
||||
startDownload: UseUpdateCheckResult['startDownload'];
|
||||
isUpdateDemoMode: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
|
||||
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
|
||||
const { t } = useI18n();
|
||||
const { openExternal, getApplicationInfo } = useApplicationBackend();
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
|
||||
@@ -94,9 +96,17 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
}, [getApplicationInfo]);
|
||||
|
||||
// Check if demo mode is enabled for development testing
|
||||
const isUpdateDemoMode = typeof window !== 'undefined' &&
|
||||
window.localStorage?.getItem('debug.updateDemo') === '1';
|
||||
const handleOpenExternal = async (url: string) => {
|
||||
try {
|
||||
await openExternal(url);
|
||||
} catch (err) {
|
||||
console.warn("[SettingsApplicationTab] openExternal failed:", err);
|
||||
toast.error(
|
||||
t("settings.application.openExternal.failedBody"),
|
||||
t("settings.application.openExternal.failedTitle"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
// In demo mode, allow checking even for dev builds
|
||||
@@ -150,7 +160,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
{/* Update badge - reflects auto-download state */}
|
||||
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
|
||||
<button
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
|
||||
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
updateState.autoDownloadStatus === 'ready'
|
||||
@@ -177,7 +187,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
disabled={updateState.isChecking}
|
||||
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
|
||||
>
|
||||
{updateState.isChecking ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
@@ -200,25 +210,25 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
icon={<Bug size={18} />}
|
||||
title={t("settings.application.reportProblem")}
|
||||
subtitle={t("settings.application.reportProblem.subtitle")}
|
||||
onClick={() => void openExternal(issueUrl)}
|
||||
onClick={() => void handleOpenExternal(issueUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<MessageCircle size={18} />}
|
||||
title={t("settings.application.community")}
|
||||
subtitle={t("settings.application.community.subtitle")}
|
||||
onClick={() => void openExternal(discussionsUrl)}
|
||||
onClick={() => void handleOpenExternal(discussionsUrl)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Github size={18} />}
|
||||
title="GitHub"
|
||||
subtitle={t("settings.application.github.subtitle")}
|
||||
onClick={() => void openExternal(REPO_URL)}
|
||||
onClick={() => void handleOpenExternal(REPO_URL)}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Newspaper size={18} />}
|
||||
title={t("settings.application.whatsNew")}
|
||||
subtitle={t("settings.application.whatsNew.subtitle")}
|
||||
onClick={() => void openExternal(releasesUrl)}
|
||||
onClick={() => void handleOpenExternal(releasesUrl)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
@@ -63,6 +65,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -84,6 +88,8 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
@@ -111,6 +117,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
@@ -130,8 +137,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -149,7 +156,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -256,6 +263,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
openReleasePage={openReleasePage}
|
||||
installUpdate={installUpdate}
|
||||
startDownload={startDownload}
|
||||
isUpdateDemoMode={isUpdateDemoMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -277,6 +286,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setUiLanguage={settings.setUiLanguage}
|
||||
customCSS={settings.customCSS}
|
||||
setCustomCSS={settings.setCustomCSS}
|
||||
showRecentHosts={settings.showRecentHosts}
|
||||
setShowRecentHosts={settings.setShowRecentHosts}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -331,6 +346,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
openReleasePage={openReleasePage}
|
||||
startDownload={startDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
@@ -31,12 +31,17 @@ import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
|
||||
import { SftpContextProvider } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
/** The host to connect to (follows focused terminal) */
|
||||
activeHost: Host | null;
|
||||
initialLocation?: { hostId: string; path: string } | null;
|
||||
@@ -55,6 +60,8 @@ interface SftpSidePanelProps {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
onGetTerminalCwd?: () => Promise<string | null>;
|
||||
@@ -65,6 +72,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
activeHost,
|
||||
initialLocation,
|
||||
showWorkspaceHostHeader = false,
|
||||
@@ -76,6 +84,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
onGetTerminalCwd,
|
||||
@@ -109,6 +119,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
} = useSftpBackend();
|
||||
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -119,6 +130,17 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: isVisible && hasPaneFocus,
|
||||
});
|
||||
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const getOpenerForFileRef = useRef(getOpenerForFile);
|
||||
@@ -130,10 +152,60 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, []);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
// Writing to it here would corrupt SftpView's left pane visibility.
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
@@ -168,6 +240,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -432,6 +505,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
// Filter transfers to those relevant to the active connection's host,
|
||||
// so workspace focus switches don't show transfers from other hosts.
|
||||
const filtered = sftp.transfers.filter((t) => {
|
||||
if (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
|
||||
if (connection.isLocal) {
|
||||
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
||||
}
|
||||
@@ -504,9 +578,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={panelRootRef}
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
onClick={handlePaneFocus}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
@@ -546,8 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={isVisible && hasPaneFocus}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
@@ -558,6 +638,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpTransferQueue
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
allTransfers={sftp.transfers}
|
||||
canRevealTransferTarget={canRevealTransferTarget}
|
||||
onRevealTransferTarget={handleRevealTransferTarget}
|
||||
/>
|
||||
@@ -590,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -608,6 +691,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.activeHost === next.activeHost &&
|
||||
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
||||
prev.isVisible === next.isVisible &&
|
||||
@@ -618,6 +702,8 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
||||
|
||||
@@ -19,11 +19,13 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
@@ -39,6 +41,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -48,22 +52,41 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -84,7 +107,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() =>
|
||||
hosts.map(h => {
|
||||
if (!h.group) return h;
|
||||
const defaults = resolveGroupDefaults(h.group, groupConfigs);
|
||||
return applyGroupDefaults(h, defaults);
|
||||
}),
|
||||
[hosts, groupConfigs],
|
||||
);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get backend helpers for file downloads and local filesystem writes.
|
||||
const {
|
||||
@@ -94,6 +127,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
listSftp,
|
||||
mkdirLocal,
|
||||
deleteLocalFile,
|
||||
listLocalDir,
|
||||
} = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -114,6 +148,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
@@ -121,8 +156,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
} else {
|
||||
// Focus side changed — clear other panes but keep the newly focused pane intact.
|
||||
keepOnlyActivePaneSelections(sftpRef.current, side);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
@@ -190,10 +235,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
selectDirectory,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
listLocalFiles: listLocalDir,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
() => [...sftp.transfers].reverse().slice(0, 5),
|
||||
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
@@ -236,6 +282,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handleAddTabLeft, handlePaneFocus]);
|
||||
|
||||
const handleAddTabRightWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabRight();
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handleAddTabRight, handlePaneFocus]);
|
||||
|
||||
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabLeft(tabId);
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabLeft]);
|
||||
|
||||
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabRight(tabId);
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
@@ -246,6 +312,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
rightCallbacks={rightCallbacks}
|
||||
>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"absolute inset-0 min-h-0 flex flex-col",
|
||||
isActive ? "z-20" : "",
|
||||
@@ -275,9 +342,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeft}
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
/>
|
||||
@@ -293,6 +360,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
||||
@@ -332,9 +402,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRight}
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
/>
|
||||
@@ -350,6 +420,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader={false}
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
||||
@@ -394,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
@@ -408,7 +483,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
};
|
||||
|
||||
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap;
|
||||
|
||||
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
||||
SftpView.displayName = "SftpView";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Combobox, ComboboxOption } from './ui/combobox';
|
||||
@@ -18,6 +18,7 @@ import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { SortDropdown, SortMode } from './ui/sort-dropdown';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
@@ -720,6 +721,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
title={t('snippets.targets.add')}
|
||||
layout="inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -730,6 +732,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
open={true}
|
||||
onClose={handleClosePanel}
|
||||
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
|
||||
layout="inline"
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -883,7 +886,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</AsidePanelContent>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleSubmit}
|
||||
@@ -891,7 +894,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
>
|
||||
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
}
|
||||
@@ -905,6 +908,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
|
||||
showBackButton={true}
|
||||
onBack={handleClosePanel}
|
||||
layout="inline"
|
||||
>
|
||||
{/* History List */}
|
||||
<div
|
||||
@@ -951,8 +955,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex gap-3 relative">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full min-h-0 flex relative">
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-2">
|
||||
{/* Search box */}
|
||||
@@ -1059,7 +1064,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1079,11 +1084,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => setSelectedPackage(pkg.path)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{pkg.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
|
||||
</div>
|
||||
@@ -1114,7 +1119,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
"group cursor-pointer overflow-hidden",
|
||||
viewMode === 'grid'
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
|
||||
@@ -1126,15 +1131,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
}}
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<FileCode size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
|
||||
{snippet.command}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
@@ -1254,6 +1266,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
shouldScrollOnTerminalInput,
|
||||
} from "../domain/terminalScroll";
|
||||
import {
|
||||
resolveHostTerminalFontFamilyId,
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
|
||||
@@ -46,14 +45,18 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
@@ -110,7 +113,8 @@ interface TerminalProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
allHosts?: Host[];
|
||||
chainHosts?: Host[];
|
||||
themePreviewId?: string;
|
||||
knownHosts?: KnownHost[];
|
||||
isVisible: boolean;
|
||||
inWorkspace?: boolean;
|
||||
@@ -120,6 +124,7 @@ interface TerminalProps {
|
||||
fontFamilyId: string;
|
||||
fontSize: number;
|
||||
terminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme?: boolean;
|
||||
terminalSettings?: TerminalSettings;
|
||||
sessionId: string;
|
||||
startupCommand?: string;
|
||||
@@ -183,7 +188,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
allHosts = [],
|
||||
chainHosts = [],
|
||||
themePreviewId,
|
||||
knownHosts: _knownHosts = [],
|
||||
isVisible,
|
||||
inWorkspace,
|
||||
@@ -193,6 +199,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
fontFamilyId,
|
||||
fontSize,
|
||||
terminalTheme,
|
||||
followAppTerminalTheme = false,
|
||||
terminalSettings,
|
||||
sessionId,
|
||||
startupCommand,
|
||||
@@ -233,11 +240,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const searchAddonRef = useRef<SearchAddon | null>(null);
|
||||
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
|
||||
const knownCwdRef = useRef<string | undefined>(undefined);
|
||||
const disposeDataRef = useRef<(() => void) | null>(null);
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
const sessionRef = useRef<string | null>(null);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
// Token for an in-flight retry chain. handleRetry sets this to a fresh
|
||||
// symbol; any cancel/close/teardown/subsequent-retry invalidates it. The
|
||||
// chained xterm.write callbacks verify the token before proceeding so a
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
@@ -245,10 +260,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
terminalSettingsRef.current = terminalSettings;
|
||||
onTerminalDataCaptureRef.current = onTerminalDataCapture;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
isVisibleRef.current = isVisible;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
@@ -296,6 +313,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
@@ -317,6 +339,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
|
||||
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
|
||||
// look different from dynamically-set ones. After text appears on screen (status
|
||||
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
|
||||
useEffect(() => {
|
||||
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
|
||||
fontWeightFixupDoneRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (!termRef.current) return;
|
||||
// Re-read the current weight at fire time to avoid stale closures
|
||||
const w = termRef.current.options.fontWeight;
|
||||
if (w === 'normal' || w === 400) return;
|
||||
termRef.current.options.fontWeight = 'normal';
|
||||
termRef.current.options.fontWeight = w;
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status]);
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
@@ -347,19 +386,191 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
// Serial line mode: buffer text and handle local echo instead of direct send
|
||||
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
const line = serialLineBufferRef.current + "\r";
|
||||
terminalBackend.writeToSession(id, line);
|
||||
serialLineBufferRef.current = "";
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
|
||||
} else if (ch === "\x15") {
|
||||
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
|
||||
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
|
||||
}
|
||||
serialLineBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
if (serialLineBufferRef.current.length > 0) {
|
||||
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
||||
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
|
||||
}
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
serialLineBufferRef.current += ch;
|
||||
if (serialConfig?.localEcho) termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
// Still update commandBuffer and broadcast for serial line mode
|
||||
// (fall through to shared bookkeeping below — don't return early)
|
||||
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
||||
// Serial character mode with local echo: echo accepted text locally
|
||||
terminalBackend.writeToSession(id, text);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
termRef.current?.write("\r\n");
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
termRef.current?.write(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminalBackend.writeToSession(id, text);
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(text, sessionId);
|
||||
}
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
const cmd = commandBufferRef.current.trim();
|
||||
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\x15") {
|
||||
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
|
||||
commandBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
// Backspace: remove last character (Windows fuzzy replacement uses \b)
|
||||
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
commandBufferRef.current += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
|
||||
useEffect(() => {
|
||||
knownCwdRef.current = undefined;
|
||||
}, [sessionId, host.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
return;
|
||||
}
|
||||
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (!cancelled && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isSupportedOs,
|
||||
isConnected: status === 'connected',
|
||||
isVisible,
|
||||
});
|
||||
|
||||
const zmodem = useZmodemTransfer(sessionId);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (zmodem.active) {
|
||||
zmodemToastedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (zmodemToastedRef.current) return;
|
||||
if (zmodem.error) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.error(zmodem.error, 'ZMODEM');
|
||||
} else if (zmodem.filename) {
|
||||
zmodemToastedRef.current = true;
|
||||
toast.success(
|
||||
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
|
||||
'ZMODEM',
|
||||
);
|
||||
}
|
||||
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
lastToastedErrorRef.current = null;
|
||||
@@ -411,27 +622,56 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
|
||||
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
|
||||
const effectiveFontSize = useMemo(
|
||||
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
|
||||
[fontSize, hasFontSizeOverride, host.fontSize],
|
||||
);
|
||||
const effectiveFontWeight = useMemo(
|
||||
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
|
||||
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
|
||||
);
|
||||
const resolvedFontFamily = useMemo(() => {
|
||||
const hostFontId = hasFontFamilyOverride && host.fontFamily
|
||||
? host.fontFamily
|
||||
: fontFamilyId;
|
||||
const resolvedFontId = hostFontId || "menlo";
|
||||
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
|
||||
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
|
||||
// When "Follow Application Theme" is on and there's no active
|
||||
// preview, skip per-host overrides — all terminals should use the
|
||||
// UI-matched theme passed via terminalTheme prop.
|
||||
if (followAppTerminalTheme && !themePreviewId) return terminalTheme;
|
||||
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
|
||||
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
|
||||
terminalTheme.id,
|
||||
);
|
||||
if (themeId) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|
||||
|| customThemes.find((t) => t.id === themeId);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host, terminalTheme, customThemes]);
|
||||
}, [customThemes, followAppTerminalTheme, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
?.map((id) => allHosts.find((h) => h.id === id))
|
||||
.filter(Boolean) as Host[]) || [];
|
||||
chainHosts;
|
||||
|
||||
const updateStatus = (next: TerminalSession["status"]) => {
|
||||
setStatus(next);
|
||||
hasConnectedRef.current = next === "connected";
|
||||
onStatusChange?.(sessionId, next);
|
||||
};
|
||||
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
|
||||
const captureHandler = onTerminalDataCaptureRef.current;
|
||||
if (!captureHandler || terminalDataCapturedRef.current) return;
|
||||
terminalDataCapturedRef.current = true;
|
||||
captureHandler(capturedSessionId, data);
|
||||
}, []);
|
||||
|
||||
const cleanupSession = () => {
|
||||
disposeDataRef.current?.();
|
||||
@@ -450,6 +690,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
retryTokenRef.current = null;
|
||||
cleanupSession();
|
||||
xtermRuntimeRef.current?.dispose();
|
||||
xtermRuntimeRef.current = null;
|
||||
@@ -499,7 +740,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
@@ -508,6 +749,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
pendingOutputScrollRef.current = false;
|
||||
@@ -544,7 +786,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onCwdChange: (cwd: string) => {
|
||||
knownCwdRef.current = cwd;
|
||||
},
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -619,11 +867,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (onTerminalDataCapture && serializeAddonRef.current) {
|
||||
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
|
||||
try {
|
||||
const terminalData = serializeAddonRef.current.serialize();
|
||||
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
|
||||
onTerminalDataCapture(sessionId, terminalData);
|
||||
handleTerminalDataCaptureOnce(sessionId, terminalData);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to serialize terminal data on unmount:", err);
|
||||
}
|
||||
@@ -631,7 +879,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
teardown();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
|
||||
}, [host.id, sessionId]);
|
||||
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
|
||||
|
||||
// Connection timeline and timeout visuals
|
||||
useEffect(() => {
|
||||
@@ -696,6 +944,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!options?.force) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (lastSize && lastSize.width === width && lastSize.height === height) {
|
||||
autocompleteRepositionRef.current?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -704,6 +953,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
try {
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
fitAddon.fit();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
autocompleteRepositionRef.current?.();
|
||||
});
|
||||
} else {
|
||||
autocompleteRepositionRef.current?.();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Fit failed", err);
|
||||
}
|
||||
@@ -719,21 +975,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
|
||||
useLayoutEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
scrollbarSliderBackground: effectiveTheme.colors.foreground + '33',
|
||||
scrollbarSliderHoverBackground: effectiveTheme.colors.foreground + '66',
|
||||
scrollbarSliderActiveBackground: effectiveTheme.colors.foreground + '80',
|
||||
};
|
||||
}
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
termRef.current.options.fontFamily = resolvedFontFamily;
|
||||
|
||||
if (terminalSettings) {
|
||||
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
|
||||
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = terminalSettings.fontWeight as
|
||||
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
|
||||
termRef.current.options.fontWeight = effectiveFontWeight as
|
||||
| 100
|
||||
| 200
|
||||
| 300
|
||||
@@ -748,10 +1012,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (typeof document === "undefined" || !document.fonts?.check) {
|
||||
return terminalSettings.fontWeightBold;
|
||||
}
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
return document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
})();
|
||||
|
||||
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
|
||||
@@ -780,27 +1044,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
lastFittedSizeRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [fontSize, effectiveTheme, terminalSettings, host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (termRef.current) {
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
termRef.current.options.fontSize = effectiveFontSize;
|
||||
|
||||
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
|
||||
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
|
||||
termRef.current.options.fontFamily = fontObj.family;
|
||||
|
||||
termRef.current.options.theme = {
|
||||
...effectiveTheme.colors,
|
||||
selectionBackground: effectiveTheme.colors.selection,
|
||||
};
|
||||
|
||||
setTimeout(() => safeFit({ force: true }), 50);
|
||||
}
|
||||
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
@@ -848,12 +1098,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
if (terminalSettings && termRef.current) {
|
||||
const fontFamily = termRef.current.options?.fontFamily || "";
|
||||
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
|
||||
if (typeof document !== "undefined" && document.fonts?.check) {
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
|
||||
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
|
||||
const resolvedBold = document.fonts.check(weightSpec)
|
||||
? terminalSettings.fontWeightBold
|
||||
: terminalSettings.fontWeight;
|
||||
: effectiveFontWeight;
|
||||
termRef.current.options.fontWeightBold = resolvedBold as
|
||||
| 100
|
||||
| 200
|
||||
@@ -884,7 +1133,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
|
||||
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
|
||||
@@ -921,12 +1170,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !fitAddonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// Fit twice: once after initial layout (100ms) and again after layout settles
|
||||
// (350ms) to handle race conditions during split operations where the container
|
||||
// dimensions may not be final on the first pass.
|
||||
const timer1 = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
const timer2 = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
}, 350);
|
||||
return () => { clearTimeout(timer1); clearTimeout(timer2); };
|
||||
}, [inWorkspace, isVisible]);
|
||||
|
||||
// When search bar opens/closes, re-fit terminal and maintain scroll position
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term || !fitAddonRef.current) return;
|
||||
const buffer = term.buffer.active;
|
||||
const wasAtBottom = buffer.viewportY >= buffer.baseY;
|
||||
const prevViewportY = buffer.viewportY;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ force: true, requireVisible: true });
|
||||
requestAnimationFrame(() => {
|
||||
if (wasAtBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
term.scrollToLine(prevViewportY);
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
|
||||
if (shouldAutoFocus) {
|
||||
@@ -1022,6 +1297,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
@@ -1039,7 +1316,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
window.removeEventListener("resize", handler);
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible]);
|
||||
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
@@ -1128,6 +1405,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
retryTokenRef.current = null;
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
auth.setAuthRetryMessage(null);
|
||||
@@ -1147,6 +1425,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const handleCloseDisconnectedSession = () => {
|
||||
retryTokenRef.current = null;
|
||||
onCloseSession?.(sessionId);
|
||||
};
|
||||
|
||||
@@ -1188,24 +1467,68 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const handleRetry = () => {
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
const term = termRef.current;
|
||||
// Claim a fresh retry token. If the user cancels / closes / unmounts /
|
||||
// kicks off another retry while the chained writes below are still
|
||||
// queued, the token will be invalidated and our callbacks will abort
|
||||
// before opening a ghost backend session with no owning UI.
|
||||
const retryToken = Symbol("retry");
|
||||
retryTokenRef.current = retryToken;
|
||||
const retryStillActive = () => retryTokenRef.current === retryToken && termRef.current === term;
|
||||
|
||||
auth.resetForRetry();
|
||||
terminalDataCapturedRef.current = false;
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setIsDisconnectedDialogDismissed(false);
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
setShowLogs(true);
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(termRef.current);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(termRef.current);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(termRef.current);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(termRef.current);
|
||||
} else {
|
||||
sessionStarters.startSSH(termRef.current);
|
||||
}
|
||||
|
||||
const startNewSession = () => {
|
||||
if (!retryStillActive()) return;
|
||||
if (host.protocol === "serial") {
|
||||
sessionStarters.startSerial(term);
|
||||
} else if (host.protocol === "local" || host.hostname === "localhost") {
|
||||
sessionStarters.startLocal(term);
|
||||
} else if (host.protocol === "telnet") {
|
||||
sessionStarters.startTelnet(term);
|
||||
} else if (host.moshEnabled) {
|
||||
sessionStarters.startMosh(term);
|
||||
} else {
|
||||
sessionStarters.startSSH(term);
|
||||
}
|
||||
};
|
||||
|
||||
// Chain the whole preparation through xterm.write callbacks so everything
|
||||
// lands in strict order — see #695. xterm.write is async, so without
|
||||
// chaining, a fast reconnect path (local/serial especially) can interleave
|
||||
// the new session's first bytes with our reset sequence, corrupting the
|
||||
// first screen.
|
||||
//
|
||||
// 1. Exit the alternate screen first. preserveTerminalViewportInScrollback
|
||||
// is a no-op on the alt buffer (disconnect while in vim/less/top), so
|
||||
// we must be on the normal buffer before preserving.
|
||||
term.write('\x1b[?1049l', () => {
|
||||
if (!retryStillActive()) return;
|
||||
// 2. Push the previous session's viewport into scrollback so the user
|
||||
// can still read it after reconnect.
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
// 3. Soft terminal reset (DECSTR, \x1b[!p) resets VT220-era modes that
|
||||
// full-screen apps may have left on — DECCKM (otherwise arrow keys
|
||||
// emit SS3 and break readline history), keypad mode, SGR,
|
||||
// insert/replace, origin, cursor visibility — without clearing the
|
||||
// buffer. DECSTR does not cover xterm-specific extensions, so also
|
||||
// explicitly disable mouse tracking (1000/1002/1003/1006) and
|
||||
// bracketed paste (2004). Finally home the cursor.
|
||||
term.write(
|
||||
'\x1b[!p\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[H',
|
||||
// 4. Only now — after every prep byte has been applied to the
|
||||
// terminal — start the new session, so its first output can't
|
||||
// interleave with the reset sequence.
|
||||
startNewSession,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowConnectionDialog = status !== "connected"
|
||||
@@ -1322,6 +1645,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const terminalPreviewVars = useMemo(() => ({
|
||||
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
|
||||
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
|
||||
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
|
||||
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
|
||||
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1344,6 +1675,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
style={terminalPreviewVars}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1372,16 +1704,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
style={{
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
color: effectiveTheme.colors.foreground,
|
||||
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
|
||||
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
|
||||
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
|
||||
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
|
||||
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
|
||||
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
|
||||
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
|
||||
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
|
||||
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
|
||||
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold">
|
||||
@@ -1756,18 +2088,42 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
<div
|
||||
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
|
||||
style={{ backgroundColor: effectiveTheme.colors.background }}
|
||||
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
className="xterm-container absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
top: isSearchOpen ? "64px" : "30px",
|
||||
paddingLeft: 6,
|
||||
backgroundColor: effectiveTheme.colors.background,
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
{needsHostKeyVerification && pendingHostKeyInfo && (
|
||||
<div className="absolute inset-0 z-30 bg-background">
|
||||
<KnownHostConfirmDialog
|
||||
@@ -1847,6 +2203,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ZMODEM transfer progress indicator */}
|
||||
{zmodem.active && (
|
||||
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
|
||||
<ZmodemProgressIndicator
|
||||
transferType={zmodem.transferType}
|
||||
filename={zmodem.filename}
|
||||
transferred={zmodem.transferred}
|
||||
total={zmodem.total}
|
||||
fileIndex={zmodem.fileIndex}
|
||||
fileCount={zmodem.fileCount}
|
||||
finalizing={zmodem.finalizing}
|
||||
onCancel={zmodem.cancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -34,6 +35,8 @@ interface TextEditorModalProps {
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -122,12 +125,38 @@ const hslToHex = (hslString: string): string => {
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
|
||||
// Get background color from CSS variable
|
||||
const getBackgroundColor = (): string => {
|
||||
const bgValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--background')
|
||||
// Read a CSS custom-property and convert from HSL to hex
|
||||
const getCssColor = (varName: string, fallback: string): string => {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
|
||||
return value ? hslToHex(value) : fallback;
|
||||
};
|
||||
|
||||
interface EditorColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
primary: string;
|
||||
card: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
/** Read all UI CSS variables that matter for the Monaco theme. */
|
||||
const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
|
||||
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
|
||||
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
|
||||
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
|
||||
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
@@ -138,6 +167,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
@@ -151,55 +182,71 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track background color for custom theme
|
||||
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes based on UI background color
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
// Define dark theme with custom background
|
||||
const colors = getEditorColors(isDarkTheme);
|
||||
|
||||
const themeColors: Record<string, string> = {
|
||||
'editor.background': colors.bg,
|
||||
'editor.foreground': colors.fg,
|
||||
'editorCursor.foreground': colors.primary,
|
||||
'editor.selectionBackground': colors.primary + '40',
|
||||
'editor.inactiveSelectionBackground': colors.primary + '25',
|
||||
'editorLineNumber.foreground': colors.mutedFg,
|
||||
'editorLineNumber.activeForeground': colors.fg,
|
||||
'editor.lineHighlightBackground': colors.fg + '08',
|
||||
'editorWidget.background': colors.card,
|
||||
'editorWidget.foreground': colors.fg,
|
||||
'editorWidget.border': colors.border,
|
||||
'input.background': colors.card,
|
||||
'input.foreground': colors.fg,
|
||||
'input.border': colors.border,
|
||||
};
|
||||
|
||||
monaco.editor.defineTheme('netcatty-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Define light theme with custom background
|
||||
monaco.editor.defineTheme('netcatty-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': bgColor,
|
||||
},
|
||||
colors: themeColors,
|
||||
});
|
||||
|
||||
// Apply the current theme
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, bgColor, customThemeName]);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class and style
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateTheme = () => {
|
||||
setIsDarkTheme(root.classList.contains('dark'));
|
||||
setBgColor(getBackgroundColor());
|
||||
setThemeSignal(getThemeSignal());
|
||||
};
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -215,6 +262,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
setHasChanges(content !== initialContent);
|
||||
}, [content, initialContent]);
|
||||
|
||||
const closeTabBinding = useMemo(
|
||||
() => keyBindings.find((binding) => binding.action === 'closeTab'),
|
||||
[keyBindings],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
@@ -254,6 +306,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
readClipboardTextRef.current = readClipboardText;
|
||||
}, [readClipboardText]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -316,11 +372,59 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
// Skip custom paste when focus is inside the find/replace widget so that
|
||||
// its input fields receive the pasted text via default browser behavior.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
const active = document.activeElement;
|
||||
if (active?.closest('.find-widget')) {
|
||||
// Read clipboard and insert into the find/replace input field.
|
||||
void (async () => {
|
||||
try {
|
||||
const text = await readClipboardTextRef.current();
|
||||
if (!text) return;
|
||||
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
|
||||
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
|
||||
const start = active.selectionStart ?? active.value.length;
|
||||
const end = active.selectionEnd ?? active.value.length;
|
||||
active.focus();
|
||||
active.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch {
|
||||
// Ignore – paste simply won't work
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
editorRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
|
||||
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
|
||||
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopPropagation();
|
||||
handleClose();
|
||||
}, [closeTabBinding, handleClose, hotkeyScheme]);
|
||||
|
||||
// Trigger search dialog
|
||||
const handleSearch = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
@@ -342,7 +446,12 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
|
||||
<DialogContent
|
||||
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
|
||||
hideCloseButton
|
||||
data-hotkey-close-tab="true"
|
||||
onKeyDownCapture={handleDialogKeyDownCapture}
|
||||
>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
||||
161
components/ThemeList.tsx
Normal file
161
components/ThemeList.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
|
||||
// Memoized theme item component
|
||||
export const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalTheme;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
interface ThemeListProps {
|
||||
selectedThemeId: string;
|
||||
onSelect: (themeId: string) => void;
|
||||
}
|
||||
|
||||
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const customThemes = useCustomThemes();
|
||||
const deletedSelectedTheme = useMemo(
|
||||
() => (selectedThemeId
|
||||
&& !isUiMatchTerminalThemeId(selectedThemeId)
|
||||
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
|
||||
&& !customThemes.some((theme) => theme.id === selectedThemeId)
|
||||
? selectedThemeId
|
||||
: null),
|
||||
[customThemes, selectedThemeId],
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(selectedThemeId)
|
||||
? TERMINAL_THEMES.find(theme => theme.id === selectedThemeId) || null
|
||||
: null),
|
||||
[selectedThemeId],
|
||||
);
|
||||
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = USER_VISIBLE_TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
{t('terminal.hiddenTheme.title')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{hiddenSelectedTheme.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
{t('terminal.hiddenTheme.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{deletedSelectedTheme && (
|
||||
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
Missing Theme
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{deletedSelectedTheme}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
This custom theme is no longer available. Pick another theme to replace it.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
import React from 'react';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
type AsidePanelLayout,
|
||||
} from './ui/aside-panel';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { ThemeList } from './ThemeList';
|
||||
|
||||
interface ThemeSelectPanelProps {
|
||||
open: boolean;
|
||||
@@ -16,42 +14,9 @@ interface ThemeSelectPanelProps {
|
||||
onClose: () => void;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
// Mini terminal preview component
|
||||
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
|
||||
theme,
|
||||
isSelected
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
|
||||
isSelected ? "border-primary" : "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span style={{ color: theme.colors.cyan }}>ls</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-wrap">
|
||||
<span style={{ color: theme.colors.blue }}>dir/</span>
|
||||
<span style={{ color: theme.colors.green }}>file</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>$</span>{' '}
|
||||
<span
|
||||
className="inline-block w-1 h-1.5"
|
||||
style={{ backgroundColor: theme.colors.cursor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
open,
|
||||
selectedThemeId,
|
||||
@@ -59,52 +24,8 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
onClose,
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
// Reserved for future hover preview feature
|
||||
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// All themes combined
|
||||
const allThemes = useMemo(() => {
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={theme.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-secondary/50"
|
||||
)}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onMouseEnter={() => setHoveredThemeId(theme.id)}
|
||||
onMouseLeave={() => setHoveredThemeId(null)}
|
||||
>
|
||||
<TerminalPreview theme={theme} isSelected={isSelected} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isSelected && "text-primary"
|
||||
)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<div className="text-xs text-muted-foreground">Light mode</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={open}
|
||||
@@ -112,12 +33,15 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
title="Select Color Theme"
|
||||
showBackButton={showBackButton}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
>
|
||||
<AsidePanelContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="py-2">
|
||||
{/* All themes in a single list */}
|
||||
{allThemes.map(renderThemeItem)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId || ''}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanelContent>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
import { LogView } from '../application/state/useSessionState';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -8,6 +10,7 @@ import { getEffectiveHostDistro } from '../domain/host';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
@@ -18,6 +21,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
hosts: Host[];
|
||||
sessions: TerminalSession[];
|
||||
orphanSessions: TerminalSession[];
|
||||
@@ -36,9 +40,11 @@ interface TopTabsProps {
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
}
|
||||
|
||||
// Detect local OS for local terminal tab icons
|
||||
@@ -51,10 +57,10 @@ const localOsId = (() => {
|
||||
})();
|
||||
|
||||
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
|
||||
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
|
||||
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
|
||||
const iconSize = "h-2.5 w-2.5";
|
||||
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
|
||||
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
|
||||
// Serial protocol → USB icon
|
||||
if (protocol === 'serial' || host?.protocol === 'serial') {
|
||||
@@ -65,8 +71,19 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
|
||||
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
|
||||
// Local protocol → shell-specific icon if available, else OS-specific icon
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
// Use shell icon from discovery when available
|
||||
const iconId = shellIcon || host?.localShellIcon;
|
||||
if (iconId) {
|
||||
return (
|
||||
<img
|
||||
src={getShellIconPath(iconId)}
|
||||
alt={iconId}
|
||||
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const logo = DISTRO_LOGOS[localOsId];
|
||||
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
|
||||
if (logo) {
|
||||
@@ -81,7 +98,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<TerminalSquare className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
@@ -108,22 +125,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
|
||||
// Fallback: generic server icon for remote, terminal for unknown
|
||||
if (host && host.protocol !== 'local') {
|
||||
return (
|
||||
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
|
||||
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
|
||||
<Server className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TerminalSquare className={fallbackIcon} />;
|
||||
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
|
||||
});
|
||||
SessionTabIcon.displayName = 'SessionTabIcon';
|
||||
|
||||
const sessionStatusDot = (status: TerminalSession['status']) => {
|
||||
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
|
||||
const tone = status === 'connected'
|
||||
? "bg-emerald-400"
|
||||
: status === 'connecting'
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-block h-2 w-2 rounded-full ring-2",
|
||||
tone,
|
||||
hasActivity && "session-activity-dot",
|
||||
)}
|
||||
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom window controls for Windows/Linux (frameless window)
|
||||
@@ -167,14 +195,16 @@ const WindowControls: React.FC = memo(() => {
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
|
||||
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
@@ -199,6 +229,7 @@ WindowControls.displayName = 'WindowControls';
|
||||
|
||||
const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
theme,
|
||||
followAppTerminalTheme = false,
|
||||
hosts,
|
||||
sessions,
|
||||
orphanSessions,
|
||||
@@ -217,14 +248,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionActivityMap = useSessionActivityMap();
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
const isSftpActive = activeTabId === 'sftp';
|
||||
const onSelectTab = activeTabStore.setActiveTabId;
|
||||
@@ -328,6 +362,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return map;
|
||||
}, [hosts]);
|
||||
|
||||
const workspaceActivityMap = useMemo(() => {
|
||||
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
|
||||
}, [sessionActivityMap, sessions]);
|
||||
|
||||
// Pre-compute session counts per workspace for O(1) access
|
||||
const workspacePaneCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -451,6 +489,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
if (item.type === 'session') {
|
||||
const session = item.session;
|
||||
const hasActivity = !!sessionActivityMap[session.id];
|
||||
const isBeingDragged = draggingSessionId === session.id;
|
||||
const shiftStyle = tabShiftStyles[session.id] || {};
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
|
||||
@@ -470,30 +509,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
activeTabId === session.id
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: activeTabId === session.id
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: activeTabId === session.id
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeTabId !== session.id) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{activeTabId === session.id && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
|
||||
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} shellIcon={session.localShellIcon} />
|
||||
<span className="truncate">{session.hostLabel}</span>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
|
||||
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => onCloseSession(session.id, e)}
|
||||
@@ -522,6 +587,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
if (item.type === 'workspace') {
|
||||
const workspace = item.workspace;
|
||||
const paneCount = item.paneCount;
|
||||
const hasActivity = !!workspaceActivityMap.get(workspace.id);
|
||||
const isActive = activeTabId === workspace.id;
|
||||
const isBeingDragged = draggingSessionId === workspace.id;
|
||||
const shiftStyle = tabShiftStyles[workspace.id] || {};
|
||||
@@ -542,32 +608,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-all duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
style={shiftStyle}
|
||||
style={{
|
||||
...shiftStyle,
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - before */}
|
||||
{showDropIndicatorBefore && isDraggingForReorder && (
|
||||
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
{/* Drop indicator line - after */}
|
||||
{showDropIndicatorAfter && isDraggingForReorder && (
|
||||
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
|
||||
<div
|
||||
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
||||
<LayoutGrid
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">{workspace.title}</span>
|
||||
</div>
|
||||
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
|
||||
{paneCount}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasActivity && sessionStatusDot('connected', true)}
|
||||
<div
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
|
||||
}}
|
||||
>
|
||||
{paneCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -595,18 +700,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-colors duration-150",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Active tab top accent line */}
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
|
||||
<FileText
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
|
||||
</span>
|
||||
@@ -640,8 +768,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-section="top-tabs"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
style={dragRegionNoSelect}
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
@@ -656,27 +790,66 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isVaultActive
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isVaultActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isVaultActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isVaultActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Shield size={14} /> Vaults
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"transition-colors duration-150",
|
||||
isSftpActive
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
|
||||
<Folder size={14} /> SFTP
|
||||
<FolderLock size={14} /> Vaults
|
||||
</div>
|
||||
{showSftpTab && (
|
||||
<div
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isSftpActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSftpActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable tabs container with fade masks */}
|
||||
@@ -696,7 +869,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -713,6 +886,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
@@ -727,7 +901,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
|
||||
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
|
||||
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -738,6 +912,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
@@ -750,21 +925,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -788,10 +966,15 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.orphanSessions === next.orphanSessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.orderedTabs === next.orderedTabs &&
|
||||
prev.logViews === next.logViews &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.onSyncNow === next.onSyncNow
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive &&
|
||||
prev.showSftpTab === next.showSftpTab
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
@@ -116,7 +117,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
const { hosts, keys, identities, groupConfigs } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -326,14 +327,17 @@ const TrayPanelContent: React.FC = () => {
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const host = rawHost.group
|
||||
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
|
||||
: rawHost;
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
import { ArrowUp, Square, X } from 'lucide-react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
FormEvent,
|
||||
HTMLAttributes,
|
||||
KeyboardEvent,
|
||||
@@ -17,13 +15,6 @@ import type {
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
|
||||
);
|
||||
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PromptInputSelect (thin wrappers around the project's Select component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PromptInputSelect = Select;
|
||||
|
||||
export const PromptInputSelectTrigger = forwardRef<
|
||||
ElementRef<typeof SelectTrigger>,
|
||||
ComponentPropsWithoutRef<typeof SelectTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
|
||||
'text-muted-foreground/40 hover:text-muted-foreground/70',
|
||||
'focus:ring-0 focus:ring-offset-0',
|
||||
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
|
||||
|
||||
export const PromptInputSelectContent = SelectContent;
|
||||
export const PromptInputSelectItem = SelectItem;
|
||||
export const PromptInputSelectValue = SelectValue;
|
||||
|
||||
@@ -5,6 +5,39 @@ import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
*/
|
||||
function formatToolResult(result: unknown): string {
|
||||
let parsed = result;
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(parsed);
|
||||
if (obj && typeof obj === 'object') parsed = obj;
|
||||
} catch {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
|
||||
const parts: string[] = [];
|
||||
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
|
||||
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
|
||||
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
|
||||
parts.push(`exit code: ${obj.exitCode}`);
|
||||
}
|
||||
if (parts.length > 0) return parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed === 'string') return parsed;
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
|
||||
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
@@ -133,7 +166,7 @@ export const ToolCall = ({
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -174,10 +207,10 @@ export const ToolCall = ({
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
|
||||
<pre className={cn(
|
||||
'text-[11px] font-mono whitespace-pre-wrap break-all',
|
||||
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
|
||||
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
|
||||
)}>
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
{formatToolResult(result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ type AgentLike = {
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'copilot'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
@@ -20,7 +21,7 @@ type AgentIconKey =
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
copilot: {
|
||||
src: '/ai/agents/copilot.svg',
|
||||
badgeClassName: 'border-zinc-300 bg-white',
|
||||
imageClassName: 'object-contain brightness-0',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('copilot'))) {
|
||||
return 'copilot';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -154,20 +163,14 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
|
||||
if (agent.type === 'builtin') {
|
||||
return 'Built-in terminal assistant';
|
||||
}
|
||||
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
|
||||
}
|
||||
|
||||
export const AgentIconBadge: React.FC<{
|
||||
agent: AgentLike | 'add-more';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const iconKey = getAgentIconKey(agent);
|
||||
const visual = AGENT_ICON_VISUALS[iconKey];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
@@ -187,18 +190,27 @@ export const AgentIconBadge: React.FC<{
|
||||
|
||||
if (variant === 'plain') {
|
||||
return (
|
||||
<img
|
||||
src={visual.src}
|
||||
alt=""
|
||||
<div
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
|
||||
className={cn('shrink-0', imageSize, className)}
|
||||
style={{
|
||||
maskImage: `url(${visual.src})`,
|
||||
WebkitMaskImage: `url(${visual.src})`,
|
||||
maskSize: 'contain',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskPosition: 'center',
|
||||
backgroundColor: 'currentColor',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-agent-badge=""
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden border',
|
||||
badgeSize,
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
(da) => {
|
||||
if (isSettingsManagedDiscoveredAgent(da)) {
|
||||
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
||||
}
|
||||
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
||||
},
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
@@ -208,7 +217,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<DropdownContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
|
||||
>
|
||||
{BUILTIN_AGENTS.map((agent) => (
|
||||
<AgentMenuRow
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
const MODEL_PICKER_MAX_WIDTH = 360;
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -166,10 +169,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
||||
// Permission mode chip removed — agents run in autonomous mode
|
||||
|
||||
// selectedModelId may be "model/thinking" for codex
|
||||
const selectedBaseModelId = selectedModelId?.split('/')[0];
|
||||
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
|
||||
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
|
||||
// selectedModelId may be "<modelId>/<thinkingLevel>" for codex ChatGPT models
|
||||
// (e.g. "gpt-5.4/high"). Note: custom config.toml / OpenRouter model ids
|
||||
// themselves can contain '/' (e.g. "qwen/qwen3.6-plus"), so don't just
|
||||
// split on the first '/'. Match against the full id first; only treat the
|
||||
// trailing segment as a thinking level when we find a preset whose
|
||||
// declared thinkingLevels make the combined form equal to selectedModelId.
|
||||
const { selectedPreset, selectedThinking } = (() => {
|
||||
if (!selectedModelId) return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
const direct = modelPresets.find(m => m.id === selectedModelId);
|
||||
if (direct) return { selectedPreset: direct, selectedThinking: undefined };
|
||||
const viaThinking = modelPresets.find(
|
||||
m => m.thinkingLevels?.some(level => `${m.id}/${level}` === selectedModelId),
|
||||
);
|
||||
if (viaThinking) {
|
||||
const thinking = selectedModelId.slice(viaThinking.id.length + 1);
|
||||
return { selectedPreset: viaThinking, selectedThinking: thinking };
|
||||
}
|
||||
return { selectedPreset: undefined, selectedThinking: undefined };
|
||||
})();
|
||||
const selectedBaseModelId = selectedPreset?.id;
|
||||
const modelLabel = selectedPreset
|
||||
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
|
||||
: modelName || providerName || t('ai.chat.noModel');
|
||||
@@ -229,7 +248,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled || isStreaming}
|
||||
disabled={disabled}
|
||||
className={expanded ? 'max-h-[220px]' : undefined}
|
||||
/>
|
||||
<button
|
||||
@@ -375,7 +394,13 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
if (!hasModelPicker) return;
|
||||
if (!showModelPicker) {
|
||||
const rect = modelBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
if (rect) {
|
||||
// Clamp so the popover stays inside the viewport when
|
||||
// the chip is near the right edge of a narrow AI side
|
||||
// panel.
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
|
||||
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
|
||||
}
|
||||
setActiveMenu('model');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
@@ -395,8 +420,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Select model"
|
||||
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom }}
|
||||
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
|
||||
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
|
||||
onMouseLeave={() => setHoveredModelId(null)}
|
||||
>
|
||||
{modelPresets.map(preset => {
|
||||
@@ -420,12 +445,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
|
||||
className="w-full min-w-0 flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
|
||||
<span className="flex-1 text-foreground/85">{preset.name}</span>
|
||||
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-foreground/85">{preset.name}</span>
|
||||
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50 shrink-0" />}
|
||||
</button>
|
||||
{/* Thinking level sub-menu */}
|
||||
{hasThinking && hoveredModelId === preset.id && (
|
||||
|
||||
@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
{/* Pending tool calls from the *last* assistant message are rendered
|
||||
after all tool-result messages (see below) for chronological order.
|
||||
Unresolved tool calls from earlier or cancelled messages are shown
|
||||
inline — as interrupted, or with approval controls if still pending. */}
|
||||
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pending tool calls from the last assistant message — rendered here
|
||||
(after all tool-result messages) so they appear at the bottom. */}
|
||||
{lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
|
||||
*
|
||||
* Shows a numbered list of steps with status indicators, host badges,
|
||||
* optional command previews, and action buttons.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface ExecutionPlanStep {
|
||||
description: string;
|
||||
host?: string;
|
||||
command?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
}
|
||||
|
||||
interface ExecutionPlanProps {
|
||||
steps: ExecutionPlanStep[];
|
||||
onApprove: () => void;
|
||||
onModify: () => void;
|
||||
onReject: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Status icon mapping
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function StepStatusIcon({
|
||||
status,
|
||||
}: {
|
||||
status: ExecutionPlanStep['status'];
|
||||
}) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle size={16} className="text-muted-foreground" />;
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2 size={16} className="text-blue-500 animate-spin" />
|
||||
);
|
||||
case 'completed':
|
||||
return <CheckCircle2 size={16} className="text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} className="text-destructive" />;
|
||||
case 'skipped':
|
||||
return (
|
||||
<SkipForward size={16} className="text-muted-foreground/60" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
|
||||
steps,
|
||||
onApprove,
|
||||
onModify,
|
||||
onReject,
|
||||
isExecuting,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
|
||||
<span className="text-sm font-medium">
|
||||
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Steps list */}
|
||||
<div className="divide-y divide-border/30">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2.5 transition-colors',
|
||||
step.status === 'running' && 'bg-blue-500/5',
|
||||
step.status === 'completed' && 'bg-green-500/5',
|
||||
step.status === 'failed' && 'bg-destructive/5',
|
||||
step.status === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{/* Step number + status icon */}
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
step.status === 'skipped' && 'line-through',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
{step.host && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{step.host}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{step.command && (
|
||||
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
|
||||
{step.command}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
|
||||
{isExecuting ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onReject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onModify}>
|
||||
Modify Plan
|
||||
</Button>
|
||||
<Button size="sm" onClick={onApprove}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ExecutionPlan.displayName = 'ExecutionPlan';
|
||||
|
||||
export default ExecutionPlan;
|
||||
export { ExecutionPlan };
|
||||
export type { ExecutionPlanProps, ExecutionPlanStep };
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* PermissionDialog - Modal for AI agent tool call permission requests.
|
||||
*
|
||||
* Shown when the agent needs user approval to execute a tool call.
|
||||
* Displays tool name, arguments, recommendation, and approve/reject actions.
|
||||
*/
|
||||
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Types
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
interface PermissionDialogProps {
|
||||
open: boolean;
|
||||
toolCall: { name: string; arguments: Record<string, unknown> } | null;
|
||||
recommendation: 'allow' | 'confirm' | 'deny';
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PermissionDialog: React.FC<PermissionDialogProps> = ({
|
||||
open,
|
||||
toolCall,
|
||||
recommendation,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isDenied = recommendation === 'deny';
|
||||
|
||||
// Keyboard shortcuts: Enter to approve, Escape to reject
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isDenied) {
|
||||
e.preventDefault();
|
||||
onApprove();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
[isDenied, onApprove, onReject],
|
||||
);
|
||||
|
||||
// Format arguments as readable code block content
|
||||
let formattedArgs = '';
|
||||
if (toolCall) {
|
||||
try {
|
||||
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
|
||||
} catch {
|
||||
formattedArgs = String(toolCall.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract host/session info from arguments if present
|
||||
const sessionId =
|
||||
toolCall?.arguments?.sessionId as string | undefined;
|
||||
const sessionIds =
|
||||
toolCall?.arguments?.sessionIds as string[] | undefined;
|
||||
|
||||
const recommendationBadge = () => {
|
||||
switch (recommendation) {
|
||||
case 'allow':
|
||||
return (
|
||||
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
|
||||
{t('ai.chat.recommendAllow')}
|
||||
</Badge>
|
||||
);
|
||||
case 'confirm':
|
||||
return (
|
||||
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
|
||||
{t('ai.chat.recommendConfirm')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deny':
|
||||
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
|
||||
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert
|
||||
size={20}
|
||||
className={cn(
|
||||
isDenied ? 'text-destructive' : 'text-yellow-500',
|
||||
)}
|
||||
/>
|
||||
{t('ai.chat.permissionRequired')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('ai.chat.permissionDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{toolCall && (
|
||||
<div className="space-y-3">
|
||||
{/* Tool name and recommendation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
|
||||
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{toolCall.name}
|
||||
</code>
|
||||
</div>
|
||||
{recommendationBadge()}
|
||||
</div>
|
||||
|
||||
{/* Target session(s) */}
|
||||
{(sessionId || sessionIds) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
|
||||
{sessionId && (
|
||||
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
|
||||
{sessionId}
|
||||
</code>
|
||||
)}
|
||||
{sessionIds && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sessionIds.map((id) => (
|
||||
<code
|
||||
key={id}
|
||||
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
|
||||
>
|
||||
{id}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments code block */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Deny warning */}
|
||||
{isDenied && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ai.chat.commandBlocked')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isDenied ? (
|
||||
<Button variant="destructive" onClick={onReject} className="w-full">
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('ai.chat.reject')}
|
||||
</Button>
|
||||
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
PermissionDialog.displayName = 'PermissionDialog';
|
||||
|
||||
export default PermissionDialog;
|
||||
export { PermissionDialog };
|
||||
export type { PermissionDialogProps };
|
||||
@@ -14,10 +14,12 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
@@ -28,6 +30,7 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -111,6 +114,13 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpListModels?: (
|
||||
acpCommand: string,
|
||||
acpArgs?: string[],
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
@@ -125,9 +135,14 @@ export interface TerminalSessionInfo {
|
||||
username?: string;
|
||||
protocol?: string;
|
||||
shellType?: string;
|
||||
deviceType?: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
|
||||
source: 'scope-target' | 'only-connected-in-scope';
|
||||
}
|
||||
|
||||
/** Typed accessor for the netcatty bridge on the window object. */
|
||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -186,6 +201,7 @@ export interface UseAIChatStreamingReturn {
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
) => Promise<void>;
|
||||
/** Send a message to the Catty agent (built-in). */
|
||||
sendToCattyAgent: (
|
||||
@@ -232,8 +248,10 @@ export interface SendToExternalContext {
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
defaultTargetSession?: DefaultTargetSessionHint;
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -320,6 +338,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages: Array<ModelMessage>,
|
||||
signal: AbortSignal,
|
||||
currentAssistantMsgId: string,
|
||||
advancedParams?: ProviderAdvancedParams,
|
||||
): Promise<void> => {
|
||||
const result = streamText({
|
||||
model,
|
||||
@@ -328,6 +347,11 @@ export function useAIChatStreaming({
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxIterations),
|
||||
abortSignal: signal,
|
||||
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
|
||||
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
|
||||
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
|
||||
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
|
||||
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
|
||||
});
|
||||
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
@@ -530,8 +554,18 @@ export function useAIChatStreaming({
|
||||
|
||||
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
|
||||
// avoiding plaintext key transit across the IPC boundary.
|
||||
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
|
||||
const agentProviderId = openaiProvider?.id;
|
||||
// Resolve the correct provider based on agent type:
|
||||
// - Claude agent → anthropic provider (prefer over generic custom)
|
||||
// - Codex agent → openai provider (fallback to openai-compatible custom)
|
||||
const agentProviderId = (() => {
|
||||
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
|
||||
return findManagedAgentProvider(context.providers, 'claude')?.id;
|
||||
}
|
||||
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
|
||||
return findManagedAgentProvider(context.providers, 'codex')?.id;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// Mutable flag: set after tool-result, cleared when new assistant msg is created
|
||||
let needsNewAssistantMsg = false;
|
||||
@@ -619,6 +653,8 @@ export function useAIChatStreaming({
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
context.toolIntegrationMode,
|
||||
context.defaultTargetSession,
|
||||
);
|
||||
} else {
|
||||
// Fallback: spawn as raw process
|
||||
@@ -680,6 +716,7 @@ export function useAIChatStreaming({
|
||||
username: s.username,
|
||||
protocol: s.protocol,
|
||||
shellType: s.shellType,
|
||||
deviceType: s.deviceType,
|
||||
connected: s.connected,
|
||||
})),
|
||||
permissionMode: context.globalPermissionMode,
|
||||
@@ -804,7 +841,7 @@ export function useAIChatStreaming({
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
* Host Chain Sub-Panel
|
||||
* Panel for configuring SSH jump host chain
|
||||
*/
|
||||
import { ArrowDown,Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ArrowDown,Plus,Search,X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { AsidePanel } from '../ui/aside-panel';
|
||||
import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface ChainPanelProps {
|
||||
@@ -23,6 +24,7 @@ export interface ChainPanelProps {
|
||||
onClearChain: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
@@ -36,8 +38,17 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
onClearChain,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return availableHostsForChain;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return availableHostsForChain.filter(
|
||||
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
|
||||
);
|
||||
}, [availableHostsForChain, searchQuery]);
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
@@ -45,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
title={t('hostDetails.chain.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack}>
|
||||
{t('common.save')}
|
||||
@@ -52,16 +64,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
|
||||
</p>
|
||||
<Button className="w-full h-10" onClick={() => { }}>
|
||||
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 space-y-4 w-0 min-w-full">
|
||||
{/* Chain visualization */}
|
||||
<div className="space-y-2">
|
||||
{chainedHosts.map((host, index) => (
|
||||
@@ -73,7 +76,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
)}
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -110,11 +113,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
|
||||
{availableHostsForChain.length > 0 && (
|
||||
<Card className="p-3 bg-card border-border/80">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('common.searchPlaceholder')}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{availableHostsForChain.map((host) => (
|
||||
{filteredHosts.map((host) => (
|
||||
<button
|
||||
key={host.id}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
|
||||
onClick={() => onAddHost(host.id)}
|
||||
>
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
|
||||
title={t('hostDetails.group.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { EnvVar } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
|
||||
onSave: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
onSave,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
|
||||
title={t('hostDetails.envVars.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ProxyConfig } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
}
|
||||
|
||||
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
title={t('hostDetails.proxyPanel.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -3,55 +3,12 @@
|
||||
* A modal dialog for selecting terminal themes in settings
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../../application/state/customThemeStore';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(theme.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch preview */}
|
||||
<div
|
||||
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
import { ThemeList } from '../ThemeList';
|
||||
|
||||
interface ThemeSelectModalProps {
|
||||
open: boolean;
|
||||
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Group themes by type
|
||||
const { darkThemes, lightThemes } = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
|
||||
{/* Theme List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{/* Dark Themes Section */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.darkThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{darkThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Light Themes Section */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('settings.terminal.themeModal.lightThemes')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{lightThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ThemeList
|
||||
selectedThemeId={selectedThemeId}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -8,16 +8,22 @@
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
AIToolIntegrationMode,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
findManagedAgentProvider,
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
} from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
@@ -38,6 +44,7 @@ import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
@@ -56,6 +63,8 @@ interface SettingsAITabProps {
|
||||
setActiveModelId: (id: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||
toolIntegrationMode: AIToolIntegrationMode;
|
||||
setToolIntegrationMode: (mode: AIToolIntegrationMode) => void;
|
||||
externalAgents: ExternalAgentConfig[];
|
||||
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||
defaultAgentId: string;
|
||||
@@ -70,6 +79,54 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
|
||||
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
|
||||
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: storedPath ? prevAgents : otherAgents,
|
||||
defaultAgentId: storedPath
|
||||
? defaultAgentId
|
||||
: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,6 +142,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setActiveModelId,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode,
|
||||
toolIntegrationMode,
|
||||
setToolIntegrationMode,
|
||||
externalAgents,
|
||||
setExternalAgents,
|
||||
defaultAgentId,
|
||||
@@ -113,58 +172,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = {
|
||||
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
|
||||
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
|
||||
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
defaultAgentIdRef.current = defaultAgentId;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
const setInfo = agentKey === "codex"
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
? setClaudePathInfo
|
||||
: setCopilotPathInfo;
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
? setIsResolvingClaude
|
||||
: setIsResolvingCopilot;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
@@ -174,32 +219,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
// Consolidate managed agent entries using the callback form of
|
||||
// setExternalAgents so we never depend on externalAgents directly.
|
||||
// All three agents resolve concurrently on mount — React runs
|
||||
// state updater callbacks sequentially, so updating the ref inside
|
||||
// ensures later calls see earlier defaultAgentId changes.
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
return null;
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
}, [setExternalAgents, setDefaultAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
}, [resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
const customPath = agentKey === "codex"
|
||||
? codexCustomPath
|
||||
: agentKey === "claude"
|
||||
? claudeCustomPath
|
||||
: copilotCustomPath;
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
@@ -244,18 +305,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
||||
], [externalAgents, t]);
|
||||
|
||||
const hasOpenAiProviderKey = providers.some(
|
||||
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
|
||||
);
|
||||
const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex"));
|
||||
|
||||
const refreshCodexIntegration = useCallback(async () => {
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration();
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
setCodexError(normalizeCodexBridgeError(err));
|
||||
@@ -457,16 +516,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
isResolvingPath={isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
integration={codexIntegration}
|
||||
loginSession={codexLoginSession}
|
||||
isLoading={isCodexLoading}
|
||||
hasOpenAiProviderKey={hasOpenAiProviderKey}
|
||||
hasCompatibleProvider={hasCodexCompatibleProvider}
|
||||
error={codexError}
|
||||
onRefresh={() => void refreshCodexIntegration()}
|
||||
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
|
||||
onConnect={() => void handleStartCodexLogin()}
|
||||
onCancel={() => void handleCancelCodexLogin()}
|
||||
onOpenUrl={handleOpenCodexLoginUrl}
|
||||
@@ -483,13 +542,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
isResolvingPath={isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- GitHub Copilot CLI Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
|
||||
</div>
|
||||
|
||||
<CopilotCliCard
|
||||
pathInfo={copilotPathInfo}
|
||||
isResolvingPath={isResolvingCopilot}
|
||||
customPath={copilotCustomPath}
|
||||
onCustomPathChange={setCopilotCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("copilot")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
@@ -507,13 +582,37 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<SettingRow
|
||||
label={t('ai.toolAccess.mode')}
|
||||
description={t('ai.toolAccess.description')}
|
||||
>
|
||||
<Select
|
||||
value={toolIntegrationMode}
|
||||
options={[
|
||||
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||
]}
|
||||
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* -- Web Search Section -- */}
|
||||
<WebSearchSettings
|
||||
webSearchConfig={webSearchConfig}
|
||||
|
||||
@@ -25,6 +25,12 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage: (language: string) => void;
|
||||
customCSS: string;
|
||||
setCustomCSS: (css: string) => void;
|
||||
showRecentHosts: boolean;
|
||||
setShowRecentHosts: (enabled: boolean) => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -45,6 +51,12 @@ export default function SettingsAppearanceTab(props: {
|
||||
setUiLanguage,
|
||||
customCSS,
|
||||
setCustomCSS,
|
||||
showRecentHosts,
|
||||
setShowRecentHosts,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
} = props;
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
@@ -254,6 +266,31 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.vault.title")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t('settings.vault.showRecentHosts')}
|
||||
description={t('settings.vault.showRecentHostsDesc')}
|
||||
>
|
||||
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showOnlyUngroupedHostsInRoot')}
|
||||
description={t('settings.vault.showOnlyUngroupedHostsInRootDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={showOnlyUngroupedHostsInRoot}
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
description={t('settings.vault.showSftpTabDesc')}
|
||||
>
|
||||
<Toggle checked={showSftpTab} onChange={setShowSftpTab} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.customCss")} />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -28,10 +28,12 @@ const getOpenerLabel = (
|
||||
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
@@ -39,6 +41,22 @@ export default function SettingsFileAssociationsTab() {
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectApplication) return;
|
||||
const result = await bridge.selectApplication();
|
||||
if (result) {
|
||||
setDefaultOpener('system-app', { path: result.path, name: result.name });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to select application:', e);
|
||||
} finally {
|
||||
setIsSelectingDefaultApp(false);
|
||||
}
|
||||
}, [setDefaultOpener]);
|
||||
|
||||
const handleEdit = useCallback(async (extension: string) => {
|
||||
setEditingExtension(extension);
|
||||
try {
|
||||
@@ -130,6 +148,76 @@ export default function SettingsFileAssociationsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default view mode section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setSftpDefaultViewMode('list')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDefaultViewMode === 'list'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDefaultViewMode === 'list'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDefaultViewMode === 'list' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultViewMode.list')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.listDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSftpDefaultViewMode('tree')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpDefaultViewMode === 'tree'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpDefaultViewMode === 'tree'
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpDefaultViewMode === 'tree' && (
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultViewMode.tree')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultViewMode.treeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-sync section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoSync')} />
|
||||
@@ -290,6 +378,117 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Transfer concurrency section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.transferConcurrency.desc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
value={sftpTransferConcurrency}
|
||||
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default opener section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.defaultOpener')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.desc')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => removeDefaultOpener()}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
!defaultOpener
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
!defaultOpener ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{!defaultOpener && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.defaultOpener.ask')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.askDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDefaultOpener('builtin-editor')}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
defaultOpener?.openerType === 'builtin-editor'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
defaultOpener?.openerType === 'builtin-editor' ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{defaultOpener?.openerType === 'builtin-editor' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('sftp.opener.builtInEditor')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.builtInDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectDefaultSystemApp}
|
||||
disabled={isSelectingDefaultApp}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
defaultOpener?.openerType === 'system-app'
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
defaultOpener?.openerType === 'system-app' ? "border-primary" : "border-muted-foreground/30"
|
||||
)}>
|
||||
{defaultOpener?.openerType === 'system-app' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
|
||||
? defaultOpener.systemApp.name
|
||||
: t('settings.sftp.defaultOpener.systemApp')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.defaultOpener.systemAppDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
|
||||
@@ -89,6 +89,7 @@ interface SettingsSystemTabProps {
|
||||
checkNow: () => Promise<unknown>;
|
||||
installUpdate: () => void;
|
||||
openReleasePage: () => void;
|
||||
startDownload: () => void;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -111,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
checkNow,
|
||||
installUpdate,
|
||||
openReleasePage,
|
||||
startDownload,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
@@ -463,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — shown when update found on unsupported platform, or on check error */}
|
||||
{/* Download button — shown when update found and no download in progress */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
updateState.manualCheckStatus === 'available' && (
|
||||
<Button variant="outline" size="sm" onClick={startDownload}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('update.downloadNow')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Open releases — fallback for unsupported platforms or check errors */}
|
||||
{updateState.autoDownloadStatus === 'idle' &&
|
||||
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
|
||||
<Button variant="ghost" size="sm" onClick={openReleasePage}>
|
||||
|
||||
@@ -7,14 +7,16 @@ import type {
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
|
||||
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
@@ -23,6 +25,193 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
|
||||
// Keyword highlight rules editor for global settings
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
const AddCustomRuleDialog: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editRule?: KeywordHighlightRule | null;
|
||||
onAdd: (rule: KeywordHighlightRule) => void;
|
||||
}> = ({ open, onOpenChange, editRule, onAdd }) => {
|
||||
const { t } = useI18n();
|
||||
const [label, setLabel] = useState('');
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editRule) {
|
||||
setLabel(editRule.label);
|
||||
setPattern(editRule.patterns[0] || '');
|
||||
setColor(editRule.color);
|
||||
setPatternError(null);
|
||||
} else if (!open) {
|
||||
reset();
|
||||
}
|
||||
}, [open, editRule]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!label.trim() || !pattern.trim()) return;
|
||||
try { new RegExp(pattern, 'gi'); } catch {
|
||||
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
// When editing, replace only the first pattern and keep any additional ones
|
||||
const patterns = editRule
|
||||
? [pattern, ...editRule.patterns.slice(1)]
|
||||
: [pattern];
|
||||
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
|
||||
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
|
||||
<Input
|
||||
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
|
||||
value={pattern}
|
||||
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className={cn("font-mono", patternError && "border-destructive")}
|
||||
/>
|
||||
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
|
||||
</div>
|
||||
{label.trim() && pattern.trim() && !patternError && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
|
||||
<span className="text-sm font-medium" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const KeywordHighlightRulesEditor: React.FC<{
|
||||
rules: KeywordHighlightRule[];
|
||||
onChange: (rules: KeywordHighlightRule[]) => void;
|
||||
}> = ({ rules, onChange }) => {
|
||||
const { t } = useI18n();
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
|
||||
|
||||
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{rules.map((rule) => {
|
||||
const custom = !isBuiltIn(rule.id);
|
||||
return (
|
||||
<div key={rule.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1.5">
|
||||
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
|
||||
{rule.label}
|
||||
</span>
|
||||
{custom && (
|
||||
<>
|
||||
<Pencil
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
|
||||
/>
|
||||
<Trash2
|
||||
size={10}
|
||||
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
|
||||
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex pt-2 mt-2 border-t border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" />
|
||||
{t('settings.terminal.keywordHighlight.addCustom')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
onChange(rules.map((rule) => {
|
||||
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return def ? { ...rule, color: def.color } : rule;
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddCustomRuleDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
|
||||
editRule={editingRule}
|
||||
onAdd={(rule) => {
|
||||
if (editingRule) {
|
||||
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
|
||||
} else {
|
||||
onChange([...rules, rule]);
|
||||
}
|
||||
setEditingRule(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
theme: (typeof TERMINAL_THEMES)[0];
|
||||
@@ -74,6 +263,8 @@ const ThemePreviewButton: React.FC<{
|
||||
export default function SettingsTerminalTab(props: {
|
||||
terminalThemeId: string;
|
||||
setTerminalThemeId: (id: string) => void;
|
||||
followAppTerminalTheme: boolean;
|
||||
setFollowAppTerminalTheme: (value: boolean) => void;
|
||||
terminalFontFamilyId: string;
|
||||
setTerminalFontFamilyId: (id: string) => void;
|
||||
terminalFontSize: number;
|
||||
@@ -84,10 +275,14 @@ export default function SettingsTerminalTab(props: {
|
||||
value: TerminalSettings[K],
|
||||
) => void;
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
@@ -95,6 +290,8 @@ export default function SettingsTerminalTab(props: {
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -102,6 +299,20 @@ export default function SettingsTerminalTab(props: {
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
|
||||
if (!terminalSettings.localShell) return false;
|
||||
return !discoveredShells.some(s => s.id === terminalSettings.localShell);
|
||||
});
|
||||
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
|
||||
const [customShellDraft, setCustomShellDraft] = useState("");
|
||||
|
||||
// Update showCustomShellInput once discovered shells load
|
||||
useEffect(() => {
|
||||
if (!terminalSettings.localShell) return;
|
||||
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
|
||||
}, [discoveredShells, terminalSettings.localShell]);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
@@ -114,6 +325,20 @@ export default function SettingsTerminalTab(props: {
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompleteGhostText", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompletePopupMenu", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
|
||||
updateTerminalSetting("autocompletePopupMenu", enabled);
|
||||
if (enabled) {
|
||||
updateTerminalSetting("autocompleteGhostText", false);
|
||||
}
|
||||
}, [updateTerminalSetting]);
|
||||
|
||||
// Import .itermcolors file
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -192,7 +417,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Validate shell path when it changes
|
||||
// Validate shell path when it changes (only for custom paths, not discovered shell ids)
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
const shellPath = terminalSettings.localShell;
|
||||
@@ -202,6 +427,12 @@ export default function SettingsTerminalTab(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation for discovered shell ids — only validate custom paths
|
||||
if (discoveredShells.some(s => s.id === shellPath)) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bridge?.validatePath) {
|
||||
setShellValidation(null);
|
||||
return;
|
||||
@@ -222,7 +453,7 @@ export default function SettingsTerminalTab(props: {
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [terminalSettings.localShell, t]);
|
||||
}, [terminalSettings.localShell, discoveredShells, t]);
|
||||
|
||||
// Validate directory path when it changes
|
||||
useEffect(() => {
|
||||
@@ -264,11 +495,24 @@ export default function SettingsTerminalTab(props: {
|
||||
return (
|
||||
<SettingsTabContent value="terminal">
|
||||
<SectionHeader title={t("settings.terminal.section.theme")} />
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
<div className="rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.theme.followApp")}
|
||||
description={t("settings.terminal.theme.followApp.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={followAppTerminalTheme}
|
||||
onChange={setFollowAppTerminalTheme}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{!followAppTerminalTheme && (
|
||||
<ThemePreviewButton
|
||||
theme={currentTheme}
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
@@ -676,47 +920,10 @@ export default function SettingsTerminalTab(props: {
|
||||
/>
|
||||
</div>
|
||||
{terminalSettings.keywordHighlightEnabled && (
|
||||
<div className="space-y-2.5">
|
||||
{terminalSettings.keywordHighlightRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between">
|
||||
<span className="text-sm" style={{ color: rule.color }}>
|
||||
{rule.label}
|
||||
</span>
|
||||
<label className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => {
|
||||
const newRules = terminalSettings.keywordHighlightRules.map((r) =>
|
||||
r.id === rule.id ? { ...r, color: e.target.value } : r,
|
||||
);
|
||||
updateTerminalSetting("keywordHighlightRules", newRules);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className="block w-10 h-6 rounded-md cursor-pointer border border-border/50 hover:border-border transition-colors"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
const resetRules = terminalSettings.keywordHighlightRules.map((rule) => {
|
||||
const defaultRule = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
|
||||
return defaultRule ? { ...rule, color: defaultRule.color } : rule;
|
||||
});
|
||||
updateTerminalSetting("keywordHighlightRules", resetRules);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} className="mr-2" />
|
||||
{t("settings.terminal.keywordHighlight.resetColors")}
|
||||
</Button>
|
||||
</div>
|
||||
<KeywordHighlightRulesEditor
|
||||
rules={terminalSettings.keywordHighlightRules}
|
||||
onChange={(rules) => updateTerminalSetting("keywordHighlightRules", rules)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -727,24 +934,43 @@ export default function SettingsTerminalTab(props: {
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<Input
|
||||
value={terminalSettings.localShell}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
|
||||
className={cn(
|
||||
"w-48",
|
||||
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
<select
|
||||
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={
|
||||
showCustomShellInput
|
||||
? "__custom__"
|
||||
: terminalSettings.localShell || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</option>
|
||||
{discoveredShells.map((shell) => (
|
||||
<option key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
|
||||
</select>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -844,13 +1070,130 @@ export default function SettingsTerminalTab(props: {
|
||||
options={[
|
||||
{ value: "auto", label: t("settings.terminal.rendering.auto") },
|
||||
{ value: "webgl", label: "WebGL" },
|
||||
{ value: "canvas", label: "Canvas" },
|
||||
{ value: "dom", label: "DOM" },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
|
||||
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "dom")}
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Autocomplete */}
|
||||
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
|
||||
<div className="space-y-1">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.workspaceFocus.style")}
|
||||
description={t("settings.terminal.workspaceFocus.style.desc")}
|
||||
>
|
||||
<Select
|
||||
value={workspaceFocusStyle}
|
||||
onChange={(v) => setWorkspaceFocusStyle(v as 'dim' | 'border')}
|
||||
options={[
|
||||
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
|
||||
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.enabled")}
|
||||
description={t("settings.terminal.autocomplete.enabled.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteEnabled}
|
||||
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.ghostText")}
|
||||
description={t("settings.terminal.autocomplete.ghostText.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompleteGhostText}
|
||||
onChange={handleAutocompleteGhostTextChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.autocomplete.popupMenu")}
|
||||
description={t("settings.terminal.autocomplete.popupMenu.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.autocompletePopupMenu}
|
||||
onChange={handleAutocompletePopupMenuChange}
|
||||
disabled={!terminalSettings.autocompleteEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/* Custom Shell Modal */}
|
||||
<Dialog open={customShellModalOpen} onOpenChange={setCustomShellModalOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.terminal.localShell.shell.custom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customPath")}</label>
|
||||
<Input
|
||||
value={customShellDraft}
|
||||
placeholder={t("settings.terminal.localShell.shell.placeholder")}
|
||||
onChange={(e) => setCustomShellDraft(e.target.value)}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
{shellValidation && !shellValidation.valid && shellValidation.message && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{shellValidation.message}
|
||||
</span>
|
||||
)}
|
||||
{shellValidation?.valid && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
✓ {t("settings.terminal.localShell.shell.pathValid")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setCustomShellDraft(p)}
|
||||
className="text-xs px-2 py-1 rounded-md border border-border bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomShellModalOpen(false)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateTerminalSetting("localShell", customShellDraft);
|
||||
setShowCustomShellInput(true);
|
||||
setCustomShellModalOpen(false);
|
||||
}}
|
||||
disabled={!customShellDraft.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration: CodexIntegrationStatus | null;
|
||||
loginSession: CodexLoginSession | null;
|
||||
isLoading: boolean;
|
||||
hasOpenAiProviderKey: boolean;
|
||||
hasCompatibleProvider: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onConnect: () => void;
|
||||
@@ -31,7 +31,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
integration,
|
||||
loginSession,
|
||||
isLoading,
|
||||
hasOpenAiProviderKey,
|
||||
hasCompatibleProvider,
|
||||
error,
|
||||
onRefresh,
|
||||
onConnect,
|
||||
@@ -42,6 +42,14 @@ export const CodexConnectionCard: React.FC<{
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const customConfigIncomplete = Boolean(
|
||||
integration?.state === "connected_custom_config"
|
||||
&& integration.customConfig
|
||||
&& integration.customConfig.envKey
|
||||
&& !integration.customConfig.envKeyPresent
|
||||
&& !integration.customConfig.hasHardcodedApiKey,
|
||||
);
|
||||
|
||||
const status = isResolvingPath
|
||||
? t('ai.codex.detecting')
|
||||
: !found
|
||||
@@ -52,9 +60,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
? t('ai.codex.connectedChatGPT')
|
||||
: integration?.state === "connected_api_key"
|
||||
? t('ai.codex.connectedApiKey')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
: integration?.state === "connected_custom_config"
|
||||
? customConfigIncomplete
|
||||
? t('ai.codex.customConfigIncomplete')
|
||||
: t('ai.codex.connectedCustomConfig')
|
||||
: integration?.state === "not_logged_in"
|
||||
? t('ai.codex.notConnected')
|
||||
: t('ai.codex.statusUnknown');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
@@ -62,9 +74,11 @@ export const CodexConnectionCard: React.FC<{
|
||||
? "text-amber-500"
|
||||
: loginSession?.state === "running"
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
: customConfigIncomplete
|
||||
? "text-amber-500"
|
||||
: integration?.isConnected
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const outputText = loginSession?.error
|
||||
? loginSession.error
|
||||
@@ -139,6 +153,9 @@ export const CodexConnectionCard: React.FC<{
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : integration?.state === "connected_custom_config" ? (
|
||||
// Nothing to log out of; config.toml is user-owned state.
|
||||
null
|
||||
) : integration?.isConnected ? (
|
||||
<Button variant="outline" size="sm" onClick={onLogout}>
|
||||
<LogOut size={14} className="mr-1.5" />
|
||||
@@ -157,7 +174,26 @@ export const CodexConnectionCard: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasOpenAiProviderKey && (
|
||||
{integration?.state === "connected_custom_config" && integration.customConfig && (
|
||||
<>
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.customConfigHint').replace(
|
||||
'{provider}',
|
||||
integration.customConfig.displayName || integration.customConfig.providerName,
|
||||
)}
|
||||
</p>
|
||||
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.codex.customConfigMissingEnvKey').replace(
|
||||
'{envKey}',
|
||||
integration.customConfig.envKey,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
|
||||
<p className="text-xs text-emerald-500">
|
||||
{t('ai.codex.apiKeyHint')}
|
||||
</p>
|
||||
|
||||
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.copilot.detecting')
|
||||
: found
|
||||
? t('ai.copilot.detected')
|
||||
: t('ai.copilot.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.copilot.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.copilot.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.copilot.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.copilot.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
|
||||
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
|
||||
defaultModel: provider.defaultModel ?? "",
|
||||
skipTLSVerify: provider.skipTLSVerify ?? false,
|
||||
advancedParams: provider.advancedParams ?? {},
|
||||
});
|
||||
const isCustom = provider.providerId === "custom";
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
|
||||
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
|
||||
}
|
||||
}, [provider.apiKey]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
setForm((prev) => {
|
||||
const next = { ...prev.advancedParams };
|
||||
if (raw.trim() === "" || raw.trim() === "-") {
|
||||
delete next[key];
|
||||
} else {
|
||||
const num = Number(raw);
|
||||
if (!Number.isNaN(num)) {
|
||||
next[key] = num;
|
||||
}
|
||||
}
|
||||
return { ...prev, advancedParams: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
|
||||
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
|
||||
const updates: Partial<ProviderConfig> = {
|
||||
baseURL: form.baseURL || undefined,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
skipTLSVerify: form.skipTLSVerify || undefined,
|
||||
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
|
||||
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
};
|
||||
|
||||
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
|
||||
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
|
||||
</label>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
{t('ai.providers.advancedParams')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
|
||||
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
|
||||
{/* max_tokens */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">max_tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
|
||||
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* temperature */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(0–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
|
||||
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* top_p */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(0–1)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
|
||||
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* frequency_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{/* presence_penalty */}
|
||||
<div className="space-y-1 pl-3">
|
||||
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-2–2)</span></label>
|
||||
<input
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
|
||||
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
|
||||
placeholder={t('ai.providers.advancedParams.default')}
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
|
||||
@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { CopilotCliCard } from "./CopilotCliCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
|
||||
@@ -4,19 +4,33 @@
|
||||
import type {
|
||||
AIProviderId,
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
} from "../../../../infrastructure/ai/types";
|
||||
|
||||
export type CodexIntegrationState =
|
||||
| "connected_chatgpt"
|
||||
| "connected_api_key"
|
||||
| "connected_custom_config"
|
||||
| "not_logged_in"
|
||||
| "unknown";
|
||||
|
||||
export interface CodexCustomProviderConfig {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
baseUrl: string | null;
|
||||
envKey: string | null;
|
||||
envKeyPresent: boolean;
|
||||
hasHardcodedApiKey: boolean;
|
||||
model: string | null;
|
||||
authHash: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIntegrationStatus {
|
||||
state: CodexIntegrationState;
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
exitCode: number | null;
|
||||
customConfig?: CodexCustomProviderConfig | null;
|
||||
}
|
||||
|
||||
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
|
||||
@@ -42,6 +56,7 @@ export interface ProviderFormState {
|
||||
baseURL: string;
|
||||
defaultModel: string;
|
||||
skipTLSVerify: boolean;
|
||||
advancedParams: ProviderAdvancedParams;
|
||||
}
|
||||
|
||||
export interface FetchedModel {
|
||||
@@ -55,7 +70,7 @@ export interface FetchBridge {
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
aiCodexGetIntegration?: () => Promise<CodexIntegrationStatus>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: () => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
@@ -80,6 +95,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -106,12 +128,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
export type SettingsIconId = AIProviderId | "claude" | "copilot";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
copilot: "/ai/agents/copilot.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
@@ -122,6 +145,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
copilot: "border border-zinc-300 bg-white",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
|
||||
@@ -9,37 +9,53 @@
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
export interface SftpTransferSource {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
// Types for the context
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
onRefreshTab: (tabId: string) => void;
|
||||
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry) => void;
|
||||
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
|
||||
onRangeSelect: (fileNames: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onSetFilter: (filter: string) => void;
|
||||
onCreateDirectory: (name: string) => Promise<void>;
|
||||
onCreateDirectoryAtPath: (path: string, name: string) => Promise<void>;
|
||||
onCreateFile: (name: string) => Promise<void>;
|
||||
onCreateFileAtPath: (path: string, name: string) => Promise<void>;
|
||||
onDeleteFiles: (fileNames: string[]) => Promise<void>;
|
||||
onDeleteFilesAtPath: (connectionId: string, path: string, fileNames: string[]) => Promise<void>;
|
||||
onRenameFile: (oldName: string, newName: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry) => void;
|
||||
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
// File operations
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
|
||||
onDragStart: (files: SftpTransferSource[], side: "left" | "right") => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
@@ -91,16 +107,18 @@ export interface SftpContextValue {
|
||||
// Host updater for bookmark persistence
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
|
||||
// Drag state (shared between panes)
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
}
|
||||
|
||||
export interface SftpDragContextValue {
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
|
||||
|
||||
export const useSftpContext = () => {
|
||||
const context = useContext(SftpContext);
|
||||
@@ -116,13 +134,19 @@ export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks
|
||||
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
|
||||
};
|
||||
|
||||
// Hook to get drag-related values
|
||||
// Hook to get drag-related values (reads from separate SftpDragContext)
|
||||
export const useSftpDrag = () => {
|
||||
const context = useSftpContext();
|
||||
return {
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
};
|
||||
const context = useContext(SftpDragContext);
|
||||
if (!context) {
|
||||
throw new Error("useSftpDrag must be used within SftpContextProvider");
|
||||
}
|
||||
return useMemo(
|
||||
() => ({
|
||||
draggedFiles: context.draggedFiles,
|
||||
...context.dragCallbacks,
|
||||
}),
|
||||
[context.draggedFiles, context.dragCallbacks],
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to get hosts
|
||||
@@ -140,7 +164,7 @@ export const useSftpUpdateHosts = () => {
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
@@ -156,19 +180,29 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
rightCallbacks,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
// Note: The callbacks objects should be stable (created with useMemo in parent)
|
||||
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
}),
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
[hosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
// Memoize drag context separately so only drag consumers re-render on drag state changes
|
||||
const dragValue = useMemo<SftpDragContextValue>(
|
||||
() => ({
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
}),
|
||||
[draggedFiles, dragCallbacks],
|
||||
);
|
||||
|
||||
return (
|
||||
<SftpContext.Provider value={value}>
|
||||
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
|
||||
</SftpContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Folder, Link } from 'lucide-react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
|
||||
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
|
||||
|
||||
interface SftpFileRowProps {
|
||||
entry: SftpFileEntry;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
showSelectionHighlight: boolean;
|
||||
isDragOver: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
|
||||
@@ -27,6 +28,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
entry,
|
||||
index,
|
||||
isSelected,
|
||||
showSelectionHighlight,
|
||||
isDragOver,
|
||||
columnWidths,
|
||||
onSelect,
|
||||
@@ -58,10 +60,13 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
onDrop(entry, e);
|
||||
}, [entry, onDrop]);
|
||||
const isSelectionVisible = isSelected && showSelectionHighlight;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sftp-row="true"
|
||||
data-entry-name={entry.name}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
draggable={!isParentDir}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -71,33 +76,53 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
|
||||
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
|
||||
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
|
||||
isSelectionVisible
|
||||
? "bg-accent-foreground/10 text-accent-foreground"
|
||||
: isNavDir
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-secondary/60 text-muted-foreground"
|
||||
)}>
|
||||
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
|
||||
{/* Show link indicator for symlinks */}
|
||||
{entry.type === 'symlink' && (
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
<Link
|
||||
size={8}
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5",
|
||||
isSelectionVisible ? "text-accent-foreground/80" : "text-muted-foreground",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
entry.type === 'symlink' && "italic pr-1",
|
||||
isSelectionVisible && "font-medium",
|
||||
)}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">{modifiedLabel}</span>
|
||||
<span className="text-xs text-muted-foreground truncate text-right">
|
||||
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
|
||||
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
|
||||
{isNavDir ? '--' : sizeLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate capitalize text-right">
|
||||
<span className={cn("text-xs truncate capitalize text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
|
||||
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -107,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
// Only re-render for showSelectionHighlight changes when the row is actually selected
|
||||
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.columnWidths.name !== next.columnWidths.name) return false;
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import type { Host, SftpFileEntry } from "../../types";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
|
||||
import type { useSftpState } from "../../application/state/useSftpState";
|
||||
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
|
||||
import FileOpenerDialog from "../FileOpenerDialog";
|
||||
import TextEditorModal from "../TextEditorModal";
|
||||
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
|
||||
@@ -24,8 +25,8 @@ interface SftpOverlaysProps {
|
||||
setHostSearchRight: (value: string) => void;
|
||||
handleHostSelectLeft: (host: Host | "local") => void;
|
||||
handleHostSelectRight: (host: Host | "local") => void;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
|
||||
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
|
||||
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
|
||||
showTextEditor: boolean;
|
||||
setShowTextEditor: (open: boolean) => void;
|
||||
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -35,6 +36,8 @@ interface SftpOverlaysProps {
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -43,7 +46,7 @@ interface SftpOverlaysProps {
|
||||
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
|
||||
}
|
||||
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
hosts,
|
||||
sftp,
|
||||
visibleTransfers,
|
||||
@@ -69,6 +72,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
handleSaveTextFile,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
@@ -101,7 +106,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
/>
|
||||
|
||||
{showTransferQueue && (
|
||||
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
|
||||
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} allTransfers={sftp.transfers} />
|
||||
)}
|
||||
|
||||
<SftpConflictDialog
|
||||
@@ -114,17 +119,11 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
open={!!permissionsState}
|
||||
onOpenChange={(open) => !open && setPermissionsState(null)}
|
||||
file={permissionsState?.file ?? null}
|
||||
onSave={(file, permissions) => {
|
||||
onSave={(_file, permissions) => {
|
||||
if (permissionsState) {
|
||||
const fullPath = sftp.joinPath(
|
||||
permissionsState.side === "left"
|
||||
? sftp.leftPane.connection?.currentPath || ""
|
||||
: sftp.rightPane.connection?.currentPath || "",
|
||||
file.name,
|
||||
);
|
||||
sftp.changePermissions(
|
||||
permissionsState.side,
|
||||
fullPath,
|
||||
permissionsState.fullPath,
|
||||
permissions,
|
||||
);
|
||||
}
|
||||
@@ -145,6 +144,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
@@ -160,4 +161,4 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
|
||||
import { SftpHostPicker } from "./index";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
hostLabel?: string;
|
||||
currentPath?: string;
|
||||
// New folder
|
||||
showNewFolderDialog: boolean;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
label ? (
|
||||
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
|
||||
) : null;
|
||||
|
||||
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
t,
|
||||
hostLabel,
|
||||
currentPath,
|
||||
showNewFolderDialog,
|
||||
setShowNewFolderDialog,
|
||||
newFolderName,
|
||||
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
setHostSearch,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}) => (
|
||||
}) => {
|
||||
const isSingleDeleteTarget = deleteTargets.length === 1;
|
||||
const deletePath = (() => {
|
||||
if (isSingleDeleteTarget) {
|
||||
return deleteTargets[0];
|
||||
}
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) return uniquePaths[0];
|
||||
if (uniquePaths.length > 1) return "Multiple locations";
|
||||
return currentPath;
|
||||
})();
|
||||
const showDeleteList = deleteTargets.length > 1;
|
||||
const deleteListItems = (() => {
|
||||
if (!showDeleteList) return [];
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) {
|
||||
return deleteTargets.map((target) => getFileName(target) || target);
|
||||
}
|
||||
return deleteTargets;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.deleteConfirm.desc")}
|
||||
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteTargets.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="space-y-3">
|
||||
{hostLabel || deletePath ? (
|
||||
<div className="text-xs text-muted-foreground space-y-1.5">
|
||||
{hostLabel ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
|
||||
<span className="break-all">{hostLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{deletePath ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
|
||||
<span className="break-all">{deletePath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{showDeleteList ? (
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteListItems.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { AlertCircle, ArrowDown, ChevronDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
|
||||
import type { SftpFileEntry } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { ColumnWidths, SortField, SortOrder } from "./utils";
|
||||
import type { SftpTransferSource } from "./SftpContext";
|
||||
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
|
||||
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
|
||||
import { isNavigableDirectory } from "./index";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
import { SftpFileRow } from "./index";
|
||||
@@ -20,6 +23,7 @@ interface SftpPaneFileListProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
pane: SftpPane;
|
||||
side: "left" | "right";
|
||||
isPaneFocused: boolean;
|
||||
columnWidths: ColumnWidths;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
@@ -31,8 +35,10 @@ interface SftpPaneFileListProps {
|
||||
totalHeight: number;
|
||||
sortedDisplayFiles: SftpFileEntry[];
|
||||
isDragOverPane: boolean;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
onRefresh: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onClearSelection: () => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
@@ -47,7 +53,8 @@ interface SftpPaneFileListProps {
|
||||
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
handleRowDragLeave: () => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
@@ -64,20 +71,20 @@ const SftpErrorWithLogs: React.FC<{
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm text-center px-4">{t(error)}</span>
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Unplug size={28} className="text-destructive/70" />
|
||||
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
@@ -98,10 +105,11 @@ const SftpErrorWithLogs: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
t,
|
||||
pane,
|
||||
side,
|
||||
isPaneFocused,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
@@ -115,6 +123,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
isDragOverPane,
|
||||
draggedFiles,
|
||||
onRefresh,
|
||||
onNavigateTo,
|
||||
onClearSelection,
|
||||
setShowNewFolderDialog,
|
||||
setShowNewFileDialog,
|
||||
getNextUntitledName,
|
||||
@@ -129,6 +139,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
handleRowDragLeave,
|
||||
handleEntryDrop,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
@@ -146,6 +157,39 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
return map;
|
||||
}, [sortedDisplayFiles]);
|
||||
|
||||
// Push sorted file names into the list order store for keyboard navigation
|
||||
useEffect(() => {
|
||||
const names = sortedDisplayFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
sftpListOrderStore.setItems(pane.id, names);
|
||||
return () => sftpListOrderStore.clearPane(pane.id);
|
||||
}, [sortedDisplayFiles, pane.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pane.selectedFiles.size !== 1) return;
|
||||
const selectedName = Array.from(pane.selectedFiles)[0];
|
||||
if (!selectedName) return;
|
||||
|
||||
const container = fileListRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
|
||||
.find((element) => element.dataset.entryName === selectedName);
|
||||
row?.scrollIntoView({ block: "nearest" });
|
||||
}, [fileListRef, pane.selectedFiles]);
|
||||
|
||||
// Use refs for frequently-changing values in context-menu actions
|
||||
const selectedFilesRef = useRef(pane.selectedFiles);
|
||||
selectedFilesRef.current = pane.selectedFiles;
|
||||
|
||||
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-sftp-row="true"]')) return;
|
||||
if (pane.selectedFiles.size === 0) return;
|
||||
onClearSelection();
|
||||
}, [onClearSelection, pane.selectedFiles.size]);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
<ContextMenu>
|
||||
@@ -154,6 +198,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
entry={entry}
|
||||
index={index}
|
||||
isSelected={pane.selectedFiles.has(entry.name)}
|
||||
showSelectionHighlight={isPaneFocused}
|
||||
isDragOver={dragOverEntry === entry.name}
|
||||
columnWidths={columnWidths}
|
||||
onSelect={handleRowSelect}
|
||||
@@ -179,6 +224,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{isNavigableDirectory(entry) && (
|
||||
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
|
||||
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
@@ -201,8 +251,9 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected)
|
||||
: [entry.name];
|
||||
const fileData = files.map((name) => {
|
||||
const fileName = String(name);
|
||||
@@ -210,6 +261,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
return {
|
||||
name: fileName,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
sourceConnectionId: pane.connection?.id,
|
||||
sourcePath: pane.connection?.currentPath,
|
||||
};
|
||||
});
|
||||
onCopyToOtherPane(fileData);
|
||||
@@ -218,8 +271,36 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
|
||||
{(() => {
|
||||
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
|
||||
const targetParent = getParentPath(sourceParent);
|
||||
if (sourceParent === targetParent) return null;
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const sourcePaths = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
void onMoveEntriesToPath(sourcePaths, targetParent);
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.moveToParent")}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})()}
|
||||
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
|
||||
<Pencil size={14} className="mr-2" /> {t("common.rename")}
|
||||
</ContextMenuItem>
|
||||
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
|
||||
@@ -231,9 +312,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
const files = pane.selectedFiles.has(entry.name)
|
||||
? Array.from(pane.selectedFiles)
|
||||
: [entry.name];
|
||||
const currentSelected = selectedFilesRef.current;
|
||||
const files = currentSelected.has(entry.name)
|
||||
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
|
||||
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
|
||||
openDeleteConfirm(files);
|
||||
}}
|
||||
>
|
||||
@@ -255,7 +337,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
),
|
||||
[
|
||||
columnWidths,
|
||||
dragOverEntry,
|
||||
filesByName,
|
||||
handleEntryDragOver,
|
||||
handleEntryDrop,
|
||||
@@ -263,11 +344,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
handleRowDragLeave,
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
dragOverEntry,
|
||||
isPaneFocused,
|
||||
onCopyToOtherPane,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onNavigateTo,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
openDeleteConfirm,
|
||||
@@ -297,7 +382,13 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
{renderRow(entry, index)}
|
||||
</React.Fragment>
|
||||
)),
|
||||
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
|
||||
[
|
||||
renderRow,
|
||||
rowHeight,
|
||||
shouldVirtualize,
|
||||
sortedDisplayFiles,
|
||||
visibleRows,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -307,16 +398,16 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
|
||||
gridTemplateColumns: buildSftpColumnTemplate(columnWidths),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<span>{t("sftp.columns.name")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.name")}</span>
|
||||
{sortField === "name" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
@@ -326,12 +417,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
|
||||
onClick={() => handleSort("modified")}
|
||||
>
|
||||
<span>{t("sftp.columns.modified")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.modified")}</span>
|
||||
{sortField === "modified" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
@@ -341,30 +432,30 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
|
||||
onClick={() => handleSort("size")}
|
||||
>
|
||||
{sortField === "size" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.size")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.size")}</span>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart("size", e)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
|
||||
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
|
||||
onClick={() => handleSort("type")}
|
||||
>
|
||||
{sortField === "type" && (
|
||||
<span className="text-primary">
|
||||
<span className="shrink-0 text-primary">
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{t("sftp.columns.kind")}</span>
|
||||
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -377,6 +468,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
"flex-1 min-h-0 overflow-y-auto relative",
|
||||
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
|
||||
)}
|
||||
onClick={handleBackgroundClick}
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
@@ -448,7 +540,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
|
||||
<span>
|
||||
{t("sftp.itemsCount", {
|
||||
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
|
||||
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
|
||||
})}
|
||||
{pane.selectedFiles.size > 0 &&
|
||||
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
|
||||
@@ -488,4 +580,4 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
@@ -46,11 +46,15 @@ interface SftpPaneToolbarProps {
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
onToggleBookmark: () => void;
|
||||
onAddGlobalBookmark: (path: string) => void;
|
||||
isCurrentPathGlobalBookmarked: boolean;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
showHiddenFiles: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
viewMode: 'list' | 'tree';
|
||||
onSetViewMode: (mode: 'list' | 'tree') => void;
|
||||
}
|
||||
|
||||
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
|
||||
@@ -58,7 +62,7 @@ interface SftpPaneToolbarProps {
|
||||
// always gets at least ~200px of space.
|
||||
const COLLAPSE_WIDTH = 400;
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
t,
|
||||
pane,
|
||||
onNavigateTo,
|
||||
@@ -92,14 +96,29 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
onAddGlobalBookmark,
|
||||
isCurrentPathGlobalBookmarked,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
showHiddenFiles,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
viewMode,
|
||||
onSetViewMode,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
|
||||
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
|
||||
prevDisplayConnectionIdRef.current = pane.connection?.id;
|
||||
// Sync immediately on connection change; otherwise defer until loading completes
|
||||
if (connectionChanged || !pane.loading) {
|
||||
setDisplayPath(pane.connection?.currentPath ?? "");
|
||||
}
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
|
||||
|
||||
// Observe the overall toolbar width to decide whether to collapse action buttons
|
||||
useEffect(() => {
|
||||
@@ -153,6 +172,36 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", viewMode === 'list' && "bg-secondary text-foreground")}
|
||||
aria-pressed={viewMode === 'list'}
|
||||
aria-label={t('sftp.viewMode.list')}
|
||||
onClick={() => onSetViewMode('list')}
|
||||
>
|
||||
<List size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.viewMode.list')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", viewMode === 'tree' && "bg-secondary text-foreground")}
|
||||
aria-pressed={viewMode === 'tree'}
|
||||
aria-label={t('sftp.viewMode.tree')}
|
||||
onClick={() => onSetViewMode('tree')}
|
||||
>
|
||||
<ListTree size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.viewMode.tree')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -275,6 +324,32 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
// Overflow dropdown menu items (same collapsible actions as menu items)
|
||||
const overflowMenuItems = (
|
||||
<div className="flex flex-col min-w-[140px]">
|
||||
<div role="radiogroup" aria-label={t('sftp.viewMode.label')}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
|
||||
viewMode === 'list' && "text-primary"
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={viewMode === 'list'}
|
||||
onClick={() => onSetViewMode('list')}
|
||||
>
|
||||
<List size={14} className="shrink-0" />
|
||||
{t('sftp.viewMode.list')}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
|
||||
viewMode === 'tree' && "text-primary"
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={viewMode === 'tree'}
|
||||
onClick={() => onSetViewMode('tree')}
|
||||
>
|
||||
<ListTree size={14} className="shrink-0" />
|
||||
{t('sftp.viewMode.tree')}
|
||||
</button>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -406,7 +481,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={pane.connection.currentPath}
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
@@ -440,16 +515,31 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<div className="p-2 border-b border-border/40 flex gap-1">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
className="flex-1 justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 shrink-0"
|
||||
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
|
||||
>
|
||||
{t("sftp.bookmark.addGlobal")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
@@ -458,6 +548,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
{bm.global && (
|
||||
<Globe size={10} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
@@ -578,4 +671,4 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
1543
components/sftp/SftpPaneTreeView.tsx
Normal file
1543
components/sftp/SftpPaneTreeView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user