Compare commits

...

27 Commits

Author SHA1 Message Date
陈大猫
517cbb6cee fix(ai): compress Catty requests only after 413 (#1327)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* fix(ai): compress Catty requests only after 413

* fix(ai): retry 413 after tool progress safely

* fix(ai): mark thrown 413 retries after tool progress

* fix(ai): preserve tool results in 413 retry
2026-06-09 13:11:42 +08:00
陈大猫
3bc373dbec Expand custom CSS hooks (#1326) 2026-06-09 12:36:15 +08:00
陈大猫
273fe10296 fix(ui): keep terminal tabs clear of host tree (#1325) 2026-06-09 12:12:41 +08:00
陈大猫
2a10a28cc8 fix(ai): cap Catty agent request payload to prevent HTTP 413 (#1323) (#1324)
* fix(ai): cap Catty agent request payload to prevent HTTP 413

Long-running chats accumulated full terminal tool outputs in SDK history
while token-based compaction only triggered near the model context window,
so nginx gateways could reject oversized JSON bodies before the model saw them.

Add a byte-budget pass that compresses verbose output, tail-preserving
truncation, and a safe sliding window before each Catty agent turn.

Fixes #1323

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): compaction was using unfiltered sdkMessages, fix didAdjust and emergency loop

* fix(ai): compact and retry oversized Catty requests

* fix(ai): preserve current input while guarding 413 retries

* fix(ai): avoid false 413 detection and fit oversized current input

* fix(ai): pair replayed tool results chronologically

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:51:03 +08:00
陈奇
f74645e1a4 chore: upgrade Electron to 42.3.3, @electron/asar to 4.2.0, electron-builder to 26.15.2 2026-06-08 22:48:02 +00:00
bincxz
15ec02dcae feat(tabs): smooth-scroll edge tabs toward center
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-06-09 04:37:59 +08:00
bincxz
e75c654a1a fix(tabs): keep host tree gutter outside tab scroll 2026-06-09 04:34:28 +08:00
bincxz
29b1eca1fd fix(settings): improve responsive settings controls 2026-06-09 04:05:28 +08:00
陈大猫
e2d036e710 Merge pull request #1318 from binaricat/codex/fix-tray-menu-1314
fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314)
2026-06-09 03:53:18 +08:00
bincxz
094f0abe4a fix(sftp): follow terminal cwd after submitted commands 2026-06-09 03:51:33 +08:00
bincxz
8ab2003dae fix(sftp): restore terminal cwd following 2026-06-09 03:43:49 +08:00
bincxz
b1b0c5648c fix(terminal): stabilize tab switch follow-up 2026-06-09 03:40:18 +08:00
陈大猫
36e5779d94 perf(terminal): reduce terminal tab-switch and layout jank (#1321)
* perf(terminal): smooth layout drags and faster tab switching

Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep side panels alive and guard session attach races

Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf(terminal): reduce tab switch jank

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:35:03 +08:00
陈大猫
53aef452cc fix(sftp): default unchecked opener preference for extensionless files (#1320)
Avoid accidentally persisting built-in editor as the default for all
extensionless files when double-clicking binaries without an extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:42:18 +08:00
陈大猫
3ef5a64b94 feat(settings): unify settings page card and section layout (#1316) (#1319)
Introduce shared SettingCard and SettingsSection primitives so AI, SFTP,
system, and terminal tabs use the same white-card + row control pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:24:17 +08:00
陈奇
c28db932a4 fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314) 2026-06-08 12:48:23 +00:00
陈大猫
f2c2501fa5 fix(sftp): Ctrl+V 粘贴文件夹不再变成空文件 (#1266) (#1312)
* fix(sftp): paste folders as directories instead of empty files (#1266)

Prefer Electron readClipboardFiles and extractDropEntries over clipboardData.files,
upload pasted directories via uploadExternalFolderPath, and add macOS NSFilenames
clipboard support.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): harden clipboard paste after review findings

Start extractDropEntries synchronously during paste, restore path-backed
file snapshot fallback, handle per-folder upload failures, and add
public.file-url clipboard test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:42:45 +08:00
陈大猫
b1f930a995 feat(sftp): 侧栏 SFTP 增加追随终端目录模式 (#1266) (#1310)
* feat(sftp): add follow terminal directory mode for sidebar (#1266)

Add a toolbar toggle that keeps the side-panel SFTP browser synced with the linked SSH terminal cwd, inspired by MobaXterm's follow-folder behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): probe pwd after commands when follow mode lacks OSC 7

Add a deferred getSessionPwd fallback after terminal commands when follow-terminal-cwd is enabled and the shell did not report OSC 7, and fix settings sync hook dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): repair follow toggle UI and backend cwd sync fallback

Fix SftpPaneView memo skipping follow prop updates, probe fresh pwd when OSC 7 is missing, and broaden linked terminal session resolution for sidebar follow mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): harden follow sync after review findings

Reuse shouldFollowTerminalCwdNavigate in production path, re-read connection
after async cwd probe, skip redundant navigate on toggle, and only probe pwd
when the SFTP side panel is open.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:28:30 +08:00
bincxz
de60b616cd Add structured GitHub issue templates and align in-app bug reports.
Require bug/feature reports via issue forms with automated format checks, while accepting legacy Bug: titles from older app builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:42:50 +08:00
陈大猫
6e6a0240a7 Merge pull request #1295 from binaricat/codex/fix-issue-1293
Some checks failed
build-packages / bump homebrew tap (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
2026-06-08 16:49:01 +08:00
bincxz
2e2360a9fc test(terminal): cover Kylin screenshot prompts and fix sudo fast path
Add issue #1293 screenshot-exact cases for sudo/telnet autofill and
include English password in the handleOutput fast-path bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:29:25 +08:00
陈奇
8011f4e2e8 fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
Kylin Professional's sudo prompt doesn't include the [sudo] tag and
doesn't end with a colon. The existing regex patterns required either
[sudo] or a trailing colon, causing the autofill hint to never fire.

Changes:
- Make trailing colon optional in SUDO_PROMPT_PATTERN and
  EXPLICIT_SUDO_PROMPT_PATTERN (terminalSudoAutofill.ts)
- Update fast path to not skip output containing Chinese password
  keywords (密码/口令) that lack a colon
- Make trailing colon/angle-bracket optional in telnet username and
  password prompt patterns (telnetAutoLogin.cjs)
- Also relax LAST_LOGIN_PATTERN for consistency
- Add Kylin-style test cases for both sudo and telnet auto-login
2026-06-08 16:04:20 +08:00
陈大猫
970037682c feat: add adjustable window opacity for overlay use (#1304) (#1309)
Expose whole-window transparency via setOpacity with settings and a top-bar quick control, persisting across restarts and syncing across windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:57:25 +08:00
陈大猫
42b58efc5c fix: align top bar right-side icon buttons (#1298) (#1303)
* fix: align top bar right-side icon buttons (#1298)

Unify AI/sync/theme/settings buttons to h-7 in one row aligned with tabs.
SyncStatusButton was h-8 and settings lived in a separate container, causing
misalignment. Preserve Windows spacing before window controls (mr-2).

Fixes #1298

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: align window controls with utility icons in top bar

Merge window controls into the same row as utility buttons, match h-7 height, add left margin separation, and restore rectangular gray hover for all three buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:07:07 +08:00
陈大猫
b20163d762 fix: add custom CSS hooks for terminal side panel and split view (#1302)
Expose data-section selectors for SFTP/side panel, split panes, and resizers
so custom CSS can target the correct regions. Clarify docs that
terminal-workspace-sidebar is focus-mode only.

Fixes #1301

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:11:51 +08:00
陈大猫
e0a56cbb14 fix(ai): infer MIME type from file extension for YAML and other code files (#1289)
* fix(ai): infer MIME type from file extension for YAML and other code files

When uploading YAML files (and other code/text files) via Electron,
file.type is often empty, causing the system to default to
'application/octet-stream'. AI providers reject this media type
with 'functionality not supported'.

Fix by inferring the correct MIME type from the file extension
when file.type is empty. Includes mappings for YAML, JSON, TOML,
shell scripts, and 50+ common code/text file extensions.

Fixes #1287

* fix(ai): use text/plain for all code/text files to ensure provider compatibility

Change all non-standard MIME types (text/x-*, application/x-*) to
text/plain for maximum provider compatibility. Anthropic and other
providers reject non-standard MIME types like application/x-yaml
with 'UnsupportedFunctionalityError'.

Changes:
- All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain
- Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain
- yaml/yml → text/plain (was application/x-yaml)
- dockerfile → text/plain (was text/x-dockerfile)
- Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
2026-06-07 19:05:33 +08:00
陈大猫
8dae851ea3 fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286) (#1288)
* fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286)

Two bugs in prompt detection for Chinese-locale users:

1. telnetAutoLogin: USERNAME_PROMPT_PATTERN, PASSWORD_PROMPT_PATTERN,
   and LAST_LOGIN_PATTERN only matched half-width colon ':' or '>'.
   Chinese-locale telnet prompts use full-width colon ':' (U+FF1A),
   e.g. '登录:', '密码:'. Changed [:'>] to [::'|] in all three
   patterns to accept both colon variants.

2. terminalSudoAutofill: EXPLICIT_SUDO_PROMPT_PATTERN required
   '[sudo]' (closing bracket immediately after 'sudo'), but Chinese
   sudo prompts use '[sudo: authenticate] 密码:' format where sudo
   is followed by colon. Changed \[sudo\] to \[sudo[^\]]*\] to
   match any '[sudo...]' variant, making the explicit (no-arm-needed)
   hint detection work for Chinese locale.

Fixes #1286

* fix: restore OSC stripping pattern broken in previous commit

The regex negated character class [^\x07]* was truncated to just \x07,
breaking OSC sequence stripping (e.g. window title changes embedded in
terminal output). Restore the original negated class so stripTerminalControl-
Sequences continues to remove OSC title sequences before prompt detection.

This was caught by Codex review of PR #1288.
2026-06-07 19:05:27 +08:00
179 changed files with 12043 additions and 4806 deletions

118
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Bug Report
description: Report a reproducible problem in Netcatty
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
- type: dropdown
id: platform
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Netcatty version
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
placeholder: "e.g. 1.2.3"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb)
- Homebrew
- Built from source (npm run dev / pack)
- Other
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
multiple: true
options:
- SSH connection / terminal
- SFTP / file browser
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / layout
- Crash / app won't start
- Other
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps so we can follow exactly.
placeholder: |
1. Open Netcatty and connect to host X
2. Click SFTP tab
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: |
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
For SSH errors, include redacted connection details (no passwords / private keys).
placeholder: Paste relevant log lines or attach screenshots.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I removed passwords, private keys, and other secrets from this report
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & general help
url: https://github.com/binaricat/Netcatty/discussions
about: Not sure if it is a bug? Ask in Discussions first.
- name: Latest release
url: https://github.com/binaricat/Netcatty/releases/latest
about: Check your Netcatty version before reporting.

View File

@@ -0,0 +1,72 @@
name: Feature Request
description: Suggest an improvement or new capability
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Describe the problem you are trying to solve and the change you want.
Vague requests like "make it better" may be closed.
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or frustrating today?
placeholder: When I manage 50+ hosts, I cannot ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Netcatty to do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other tools, workarounds, or designs you thought about.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
multiple: true
options:
- SSH / terminal
- SFTP
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and discussions for similar requests
required: true

139
.github/workflows/issue-format.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: issue-format
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
if: >-
github.event.issue.user.type != 'Bot' &&
!contains(github.event.issue.labels.*.name, 'format-exempt')
steps:
- name: Validate title and body
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title.trim();
const body = (issue.body || '').trim();
const errors = [];
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
if (!modernTitle && !legacyAppTitle) {
errors.push(
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
);
}
if (body.length < 120) {
errors.push(
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
);
}
const templateMarkers = [
'Steps to reproduce',
'Expected behavior',
'Actual behavior',
'Describe the problem',
'Problem / pain point',
'Proposed solution',
'Operating system',
];
const hasTemplateStructure = templateMarkers.some((marker) =>
body.includes(marker)
);
if (!hasTemplateStructure) {
errors.push(
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
);
}
const labels = new Set(
(issue.labels || []).map((label) =>
typeof label === 'string' ? label : label.name
)
);
if (errors.length === 0) {
if (
issue.state === 'closed' &&
labels.has('invalid-format')
) {
labels.delete('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'open',
labels: [...labels],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
});
}
core.info('Issue format OK');
return;
}
const issueNumber = issue.number;
const marker = '<!-- issue-format-bot -->';
const bodyText = [
marker,
'## Issue format check failed',
'',
'This issue was closed automatically because it does not follow the required format.',
'',
...errors.map((e) => `- ${e}`),
'',
'### How to resubmit',
'',
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
'2. Pick **Bug Report** or **Feature Request**',
'3. Fill in every required field',
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
'',
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
'',
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyNotified = comments.some((c) =>
(c.body || '').includes(marker)
);
if (!alreadyNotified) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: bodyText,
});
}
labels.add('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
labels: [...labels],
});

131
App.tsx
View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingState } from './application/state/usePortForwardingState';
import { useSessionState } from './application/state/useSessionState';
@@ -28,9 +27,7 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveHostTerminalThemeId,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
@@ -60,9 +57,10 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
import { PassphraseRequest } from './components/PassphraseModal';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
@@ -131,6 +129,8 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
@@ -269,16 +269,9 @@ function App({ settings }: { settings: SettingsState }) {
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// ---------------------------------------------------------------------------
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
const editorTabs = useEditorTabs();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
@@ -298,92 +291,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
// Workspace
const workspace = workspaceById.get(activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
// Split mode: require all sessions to share the same theme
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
// Single session tab
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -815,7 +724,7 @@ function App({ settings }: { settings: SettingsState }) {
}
const intent = resolveWindowCommandCloseIntent({
activeTabId,
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
sessionIds: sessions.map((session) => session.id),
workspaceIds: workspaces.map((workspace) => workspace.id),
@@ -833,7 +742,7 @@ function App({ settings }: { settings: SettingsState }) {
}
await netcattyBridge.get()?.windowClose?.();
}, [activeTabId, closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
useEffect(() => {
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
@@ -1045,7 +954,27 @@ function App({ settings }: { settings: SettingsState }) {
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
return (
<>
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
hostById={hostById}
sessionById={sessionById}
workspaceById={workspaceById}
themeById={themeById}
currentTerminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
reapplyCurrentTheme={reapplyCurrentTheme}
editorTabs={editorTabs}
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
function AppWithProviders() {

View File

@@ -0,0 +1,161 @@
import { useEffect, useMemo } from 'react';
import {
fromEditorTabId,
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { setImmersiveActive } from '../state/immersiveStore';
import { useImmersiveMode } from '../state/useImmersiveMode';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import type {
Host,
TerminalSession,
TerminalTheme,
Workspace,
} from '../../types';
import type { LogView } from '../state/logViewState';
import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
workspaceById: Map<string, Workspace>;
themeById: Map<string, TerminalTheme>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
reapplyCurrentTheme: () => void;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
t: (key: string) => string;
}
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: immersive-mode theming, window title, and the
* SFTP-tab guard. Extracted out of <App> so that switching top tabs only
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
* and TopTabs can read it without re-rendering App.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
hostById,
sessionById,
workspaceById,
themeById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
reapplyCurrentTheme,
editorTabs,
logViews,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every((s) => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
useEffect(() => {
setImmersiveActive(activeTerminalTheme !== null);
}, [activeTerminalTheme]);
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
return null;
}

View File

@@ -2,6 +2,7 @@
import React, { Suspense, lazy } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
import { useImmersiveActive } from '../state/immersiveStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { TopTabs } from '../../components/TopTabs';
@@ -32,7 +33,7 @@ type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -46,7 +47,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
@@ -55,6 +56,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Immersive flag from store (not ctx) so toggling it doesn't re-render <App>.
// Note: we intentionally do NOT subscribe to the active tab id here — editor
// tab visibility self-subscribes inside TextEditorTabView — so plain tab
// switches don't re-render AppView/App at all.
const isImmersive = useImmersiveActive();
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
@@ -106,7 +113,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
@@ -129,8 +136,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
isImmersiveActive={activeTerminalTheme !== null}
isImmersiveActive={isImmersive}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
@@ -215,6 +224,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
<TerminalLayerMount
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
@@ -259,6 +269,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
@@ -268,6 +280,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
@@ -300,7 +314,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
<TextEditorTabView
key={tab.id}
tabId={tab.id}
isVisible={activeTabId === toEditorTabId(tab.id)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}

View File

@@ -174,6 +174,8 @@ export const enAiMessages: Messages = {
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
@@ -231,9 +233,20 @@ export const enAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
'terminal.layer.hostTree.newGroup': 'New group',
'terminal.layer.hostTree.localShell': 'Local shell',
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
'terminal.layer.hostTree.clearTags': 'Clear selection',
'terminal.layer.hostTree.collapse': 'Collapse host list',
'terminal.layer.hostTree.expand': 'Expand host list',
'terminal.layer.hostTree.empty': 'No hosts found',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.windowOpacity': 'Window opacity',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',

View File

@@ -264,14 +264,15 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
@@ -593,6 +594,7 @@ export const enCoreMessages: Messages = {
'vault.groups.hostsCount': '{count} Hosts',
'vault.groups.newSubgroup': 'New Subgroup',
'vault.groups.rename': 'Rename Group',
'vault.groups.unnamed': 'Unnamed Group',
'vault.groups.delete': 'Delete Group',
'vault.groups.createSubfolder': 'Create Subfolder',
'vault.groups.createRoot': 'Create Root Group',

View File

@@ -197,6 +197,9 @@ export const enVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
@@ -348,6 +351,10 @@ export const enVaultMessages: Messages = {
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.followTerminalCwd': 'Follow terminal directory',
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',

View File

@@ -174,6 +174,8 @@ export const ruAiMessages: Messages = {
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
@@ -231,9 +233,20 @@ export const ruAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
'terminal.layer.hostTree.newGroup': 'Новая группа',
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
'terminal.layer.hostTree.empty': 'Хосты не найдены',
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',

View File

@@ -264,14 +264,15 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',

View File

@@ -232,6 +232,9 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
'sftp.encoding.label': 'Кодировка имён файлов',
'sftp.encoding.auto': 'Авто',
'sftp.encoding.utf8': 'UTF-8',
@@ -383,6 +386,10 @@ export const ruVaultMessages: Messages = {
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
'settings.sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',

View File

@@ -174,6 +174,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
@@ -231,9 +233,20 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
'terminal.layer.hostTree.newGroup': '新建分组',
'terminal.layer.hostTree.localShell': '本地 Shell',
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
'terminal.layer.hostTree.clearTags': '清除筛选',
'terminal.layer.hostTree.collapse': '收起主机列表',
'terminal.layer.hostTree.expand': '展开主机列表',
'terminal.layer.hostTree.empty': '没有匹配的主机',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.windowOpacity': '窗口透明度',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',

View File

@@ -248,14 +248,15 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
// Context menus / common actions
'action.newHost': '新建主机',
'action.newSubfolder': '新建文件夹',
@@ -367,6 +368,7 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.hostsCount': '{count} 台主机',
'vault.groups.newSubgroup': '新建子分组',
'vault.groups.rename': '重命名分组',
'vault.groups.unnamed': '未命名分组',
'vault.groups.delete': '删除分组',
'vault.groups.createSubfolder': '创建子分组',
'vault.groups.createRoot': '创建根分组',
@@ -613,6 +615,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',

View File

@@ -80,6 +80,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.followTerminalCwd': '追随终端目录',
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',

View File

@@ -1,5 +1,7 @@
import { useCallback, useSyncExternalStore } from 'react';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
@@ -18,19 +20,35 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
this.notifyRafId = schedule(() => {
this.notifyRafId = null;
this.listeners.forEach((listener) => listener());
});
};
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -47,7 +65,8 @@ export const activeTabStore = new ActiveTabStore();
export const useActiveTabId = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
};
@@ -59,7 +78,7 @@ export const useSetActiveTabId = () => {
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Stable snapshot functions - defined once outside components
@@ -70,7 +89,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
export const useIsVaultActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsVaultActive
getIsVaultActive,
getIsVaultActive,
);
};
@@ -78,7 +98,8 @@ export const useIsVaultActive = () => {
export const useIsSftpActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsSftpActive
getIsSftpActive,
getIsSftpActive,
);
};
@@ -86,7 +107,7 @@ export const useIsSftpActive = () => {
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Check if terminal layer should be visible
@@ -98,5 +119,5 @@ export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -0,0 +1,36 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class HostTreeInlineGroupDeleteStore {
private targetPath: string | null = null;
private listeners = new Set<Listener>();
getTargetPath = () => this.targetPath;
open = (groupPath: string) => {
this.targetPath = groupPath;
this.listeners.forEach((listener) => listener());
};
close = () => {
if (!this.targetPath) return;
this.targetPath = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
export const useHostTreeInlineGroupDeleteTarget = () => {
return useSyncExternalStore(
hostTreeInlineGroupDeleteStore.subscribe,
hostTreeInlineGroupDeleteStore.getTargetPath,
hostTreeInlineGroupDeleteStore.getTargetPath,
);
};

View File

@@ -0,0 +1,52 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineGroupEdit = {
groupPath: string;
initialName: string;
isNew: boolean;
shouldScrollIntoView?: boolean;
};
type Listener = () => void;
class HostTreeInlineGroupEditStore {
private edit: HostTreeInlineGroupEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineGroupEdit) => {
this.edit = {
...edit,
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
};
this.listeners.forEach((listener) => listener());
};
markScrollHandled = () => {
if (!this.edit?.shouldScrollIntoView) return;
this.edit = { ...this.edit, shouldScrollIntoView: false };
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
export const useHostTreeInlineGroupEdit = () => {
return useSyncExternalStore(
hostTreeInlineGroupEditStore.subscribe,
hostTreeInlineGroupEditStore.getEdit,
hostTreeInlineGroupEditStore.getEdit,
);
};

View File

@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react';
/**
* Tiny external store for "immersive mode active" (whether the active terminal
* tab's theme is driving the app chrome). Kept out of the App component's render
* so that toggling immersive — and tab switches in general — do not force a
* full App re-render. The owner (AppActiveTabChrome) calls setImmersiveActive;
* AppView/TopTabs read it via useImmersiveActive without re-rendering App.
*/
type Listener = () => void;
let immersiveActive = false;
const listeners = new Set<Listener>();
export function setImmersiveActive(active: boolean): void {
if (immersiveActive === active) return;
immersiveActive = active;
listeners.forEach((listener) => listener());
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): boolean {
return immersiveActive;
}
export function useImmersiveActive(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -18,6 +18,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
@@ -33,9 +34,14 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
interface UseSettingsIpcSyncParams {
syncAppearanceFromStorage: () => void;
@@ -59,8 +65,10 @@ interface UseSettingsIpcSyncParams {
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
@@ -88,8 +96,10 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState,
@@ -191,12 +201,19 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
setSftpFollowTerminalCwd((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
@@ -223,6 +240,7 @@ export function useSettingsIpcSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
@@ -231,6 +249,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,

View File

@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
export const DEFAULT_WINDOW_OPACITY = 1;
export function clampWindowOpacity(opacity: unknown): number {
const value = Number(opacity);
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
return Math.min(1, Math.max(0.5, value));
}
/** Resolve the current OS color scheme preference. */
export const getSystemPreference = (): 'light' | 'dark' =>
@@ -52,6 +58,7 @@ export const DEFAULT_SFTP_AUTO_SYNC = false;
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
export const DEFAULT_SFTP_FOLLOW_TERMINAL_CWD = false;
export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;

View File

@@ -17,6 +17,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -39,8 +40,10 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import {
clampWindowOpacity,
isValidHslToken,
isValidTheme,
isValidUiFontId,
@@ -67,6 +70,7 @@ interface UseSettingsStorageSyncParams {
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
@@ -79,6 +83,7 @@ interface UseSettingsStorageSyncParams {
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
@@ -99,6 +104,7 @@ interface UseSettingsStorageSyncParams {
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
@@ -110,6 +116,7 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
@@ -122,19 +129,19 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
@@ -145,20 +152,20 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -334,6 +341,12 @@ export function useSettingsStorageSync({
setSftpAutoOpenSidebar(newValue);
}
}
if (e.key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpFollowTerminalCwd) {
setSftpFollowTerminalCwd(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
@@ -372,6 +385,12 @@ export function useSettingsStorageSync({
setAutoUpdateEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
@@ -400,6 +419,7 @@ export function useSettingsStorageSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
@@ -408,6 +428,7 @@ export function useSettingsStorageSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpAutoSync,
setSftpDefaultViewMode,
setSftpDoubleClickBehavior,

View File

@@ -1,18 +1,21 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
optionsRef.current?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
optionsRef.current?.onFileWatchError?.(payload);
});
return () => {
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
// ignore cleanup errors
}
};
}, [options]);
}, []);
};

View File

@@ -4,6 +4,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.

View File

@@ -0,0 +1,76 @@
import { useCallback, useSyncExternalStore } from 'react';
import { STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
type Listener = () => void;
function readIsOpen(): boolean {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
// Legacy key stores "collapsed"; open is the inverse.
if (stored === 'true') return false;
if (stored === 'false') return true;
return false;
}
class TerminalHostTreeStore {
private isOpen = readIsOpen();
/** Live sidebar width (0 when collapsed) for top-tab alignment. */
private layoutWidth = 0;
private listeners = new Set<Listener>();
getIsOpen = () => this.isOpen;
getLayoutWidth = () => this.layoutWidth;
setIsOpen = (open: boolean) => {
if (this.isOpen === open) return;
this.isOpen = open;
if (!open) {
this.layoutWidth = 0;
}
localStorageAdapter.writeString(
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
open ? 'false' : 'true',
);
this.listeners.forEach((listener) => listener());
};
setLayoutWidth = (width: number) => {
const next = Math.max(0, width);
if (this.layoutWidth === next) return;
this.layoutWidth = next;
this.listeners.forEach((listener) => listener());
};
toggle = () => {
this.setIsOpen(!this.isOpen);
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const terminalHostTreeStore = new TerminalHostTreeStore();
export const useTerminalHostTreeOpen = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getIsOpen,
terminalHostTreeStore.getIsOpen,
);
};
export const useToggleTerminalHostTree = () => {
return useCallback(() => terminalHostTreeStore.toggle(), []);
};
export const useTerminalHostTreeLayoutWidth = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getLayoutWidth,
terminalHostTreeStore.getLayoutWidth,
);
};

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
assert.equal(terminalLayoutSuppressStore.getActive(), false);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), false);
});

View File

@@ -0,0 +1,40 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
let suppressDepth = 0;
const listeners = new Set<Listener>();
function emit() {
listeners.forEach((listener) => listener());
}
export const terminalLayoutSuppressStore = {
getActive: () => suppressDepth > 0,
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
begin: () => {
suppressDepth += 1;
emit();
},
end: () => {
const wasActive = suppressDepth > 0;
suppressDepth = Math.max(0, suppressDepth - 1);
if (wasActive) {
emit();
}
},
};
export function useTerminalLayoutSuppressActive(): boolean {
return useSyncExternalStore(
terminalLayoutSuppressStore.subscribe,
terminalLayoutSuppressStore.getActive,
terminalLayoutSuppressStore.getActive,
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
@@ -941,7 +941,7 @@ export function useAIState() {
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
return {
return useMemo(() => ({
providers,
setProviders,
addProvider,
@@ -996,5 +996,60 @@ export function useAIState() {
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
};
}), [
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
]);
}

View File

@@ -13,7 +13,9 @@ function getBridge(): NetcattyBridge | undefined {
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
options?: { enabled?: boolean },
) {
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
@@ -32,10 +34,28 @@ export function useAgentDiscovery(
}
}, []);
// Discover on mount
useEffect(() => {
discover();
}, [discover]);
if (!enabled) return;
let cancelled = false;
const runDiscover = () => {
if (!cancelled) void discover();
};
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
return () => {
cancelled = true;
cancelIdleCallback(idleId);
};
}
const timeoutId = setTimeout(runDiscover, 0);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [discover, enabled]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).

View File

@@ -109,11 +109,16 @@ interface RemoteVersionCheckOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const inspectFailureToastShownRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
@@ -513,7 +518,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
});
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
@@ -521,7 +526,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
}
return;
}
@@ -555,7 +560,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
return;
}
@@ -590,7 +595,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
markCurrentDataSynced = false;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
@@ -637,14 +642,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
// Surface a degraded-sync hint once per session. Retries and
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
inspectFailureToastShownRef.current = true;
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
tRef.current('sync.autoSync.inspectFailedMessage'),
tRef.current('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
@@ -677,7 +681,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
}, [t]);
// `t` is read through tRef so locale updates don't rebuild this
// callback and re-fire the startup retry effect on unrelated renders.
}, []);
const checkRemoteVersionRef = useRef(checkRemoteVersion);
useEffect(() => {
checkRemoteVersionRef.current = checkRemoteVersion;
}, [checkRemoteVersion]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -789,7 +800,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const tick = () => {
if (cancelled) return;
void (async () => {
await checkRemoteVersion();
const notifyOnFailure = attempt === 0;
await checkRemoteVersionRef.current(
notifyOnFailure ? undefined : { notifyOnFailure: false },
);
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
@@ -824,7 +838,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();

View File

@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
/**
* Infer MIME type from file extension when the browser/Electron doesn't
* provide one (common for .yaml, .sh, .toml, and other code/text files).
*/
const EXTENSION_MIME_TYPES: Record<string, string> = {
// Code & Scripts — all use text/plain for maximum provider compatibility
js: 'text/plain',
mjs: 'text/plain',
cjs: 'text/plain',
jsx: 'text/plain',
ts: 'text/plain',
tsx: 'text/plain',
py: 'text/plain',
rb: 'text/plain',
rs: 'text/plain',
go: 'text/plain',
java: 'text/plain',
c: 'text/plain',
h: 'text/plain',
cpp: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
kt: 'text/plain',
scala: 'text/plain',
php: 'text/plain',
pl: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
fish: 'text/plain',
ps1: 'text/plain',
bat: 'text/plain',
cmd: 'text/plain',
sql: 'text/plain',
r: 'text/plain',
lua: 'text/plain',
dart: 'text/plain',
// Web
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/plain',
sass: 'text/plain',
less: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
// Config / Data
yaml: 'text/plain',
yml: 'text/plain',
json: 'application/json',
jsonc: 'application/json',
jsonl: 'application/jsonl',
xml: 'application/xml',
toml: 'application/toml',
csv: 'text/csv',
tsv: 'text/tab-separated-values',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
env: 'text/plain',
// Docs
md: 'text/markdown',
markdown: 'text/markdown',
txt: 'text/plain',
tex: 'text/x-tex',
rst: 'text/x-rst',
log: 'text/plain',
// Other typed files
pdf: 'application/pdf',
dockerfile: 'text/plain',
};
function getExtension(fileName: string): string {
const dot = fileName.lastIndexOf('.');
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
return fileName.slice(dot + 1).toLowerCase();
}
function inferMediaType(fileName: string, fileType: string): string {
if (fileType) return fileType;
const ext = getExtension(fileName);
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
}
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
const mediaType = inferMediaType(filename, file.type);
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);

View File

@@ -25,6 +25,7 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -36,6 +37,7 @@ import {
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -72,6 +74,7 @@ import {
DEFAULT_SESSION_LOGS_FORMAT,
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
DEFAULT_SFTP_AUTO_SYNC,
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -83,6 +86,8 @@ import {
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
@@ -204,6 +209,10 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpFollowTerminalCwd, setSftpFollowTerminalCwd] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
return stored === 'true' ? true : DEFAULT_SFTP_FOLLOW_TERMINAL_CWD;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
@@ -278,6 +287,19 @@ export const useSettingsState = () => {
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
if (stored === null) return DEFAULT_WINDOW_OPACITY;
return clampWindowOpacity(stored);
});
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
setWindowOpacityState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev)
: nextValue;
return clampWindowOpacity(candidate);
});
}, []);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
@@ -489,6 +511,10 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (storedFollowTerminalCwd === 'true' || storedFollowTerminalCwd === 'false') {
setSftpFollowTerminalCwd(storedFollowTerminalCwd === 'true');
}
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
@@ -576,8 +602,10 @@ export const useSettingsState = () => {
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState,
@@ -605,19 +633,19 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -773,6 +801,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP follow terminal cwd setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd);
}, [sftpFollowTerminalCwd, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
@@ -815,6 +850,7 @@ export const useSettingsState = () => {
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -948,6 +984,8 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
@@ -986,6 +1024,8 @@ export const useSettingsState = () => {
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
rehydrateAllFromStorage,
reapplyCurrentTheme,
workspaceFocusStyle,
@@ -997,7 +1037,7 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export const useTreeExpandedState = (storageKey: string) => {
@@ -20,28 +20,40 @@ export const useTreeExpandedState = (storageKey: string) => {
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
}, [storageKey, expandedPaths]);
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const togglePath = useCallback((path: string) => {
setExpandedPaths((current) => {
const next = new Set(current);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandAll = (allPaths: string[]) => {
const expandAll = useCallback((allPaths: string[]) => {
setExpandedPaths(new Set(allPaths));
};
}, []);
const collapseAll = () => {
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
};
}, []);
const ensurePathExpanded = useCallback((path: string) => {
setExpandedPaths((current) => {
if (current.has(path)) return current;
const next = new Set(current);
next.add(path);
return next;
});
}, []);
return {
expandedPaths,
togglePath,
expandAll,
collapseAll,
ensurePathExpanded,
};
};

View File

@@ -109,6 +109,29 @@ const safeParse = <T,>(value: string | null): T | null => {
}
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
* scrollback for a session; with up to 500 logs it grew the
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
* add/update re-serialized + wrote the whole thing synchronously
* (5073 ms on the main thread), causing freezes on connect/disconnect.
*
* The full `terminalData` stays in the in-memory React state (so in-session
* replay still works); only explicitly *saved* logs keep it on disk. This
* keeps the persisted blob small and writes fast.
*/
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : logs;
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
@@ -318,7 +341,7 @@ export const useVaultState = () => {
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
return final;
});
return newLog.id;
@@ -332,7 +355,7 @@ export const useVaultState = () => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
return updated;
});
},
@@ -360,7 +383,7 @@ export const useVaultState = () => {
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
return saved;
});
}, []);

View File

@@ -0,0 +1,46 @@
import { useSyncExternalStore } from 'react';
import type { Host } from '../../types';
export interface VaultHostTreeActions {
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
cancelInlineGroupEdit: () => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}
type Listener = () => void;
class VaultHostTreeActionsStore {
private actions: VaultHostTreeActions | null = null;
private listeners = new Set<Listener>();
getActions = () => this.actions;
setActions = (actions: VaultHostTreeActions | null) => {
this.actions = actions;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const vaultHostTreeActionsStore = new VaultHostTreeActionsStore();
export const useVaultHostTreeActions = () => {
return useSyncExternalStore(
vaultHostTreeActionsStore.subscribe,
vaultHostTreeActionsStore.getActions,
vaultHostTreeActionsStore.getActions,
);
};

View File

@@ -56,6 +56,7 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
@@ -220,6 +221,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -386,6 +388,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (followTerminalCwd === 'true' || followTerminalCwd === 'false') settings.sftpFollowTerminalCwd = followTerminalCwd === 'true';
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
@@ -512,6 +516,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
if (settings.sftpDefaultViewMode != null) {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -19,6 +19,9 @@ interface SessionHistoryDrawerProps {
onClose: () => void;
}
const SESSION_RENDER_BATCH = 80;
const SESSION_RENDER_STEP = 60;
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
sessions,
activeSessionId,
@@ -27,6 +30,15 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
onClose,
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions]);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
@@ -47,7 +59,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
</p>
</div>
) : (
sessions.map((session) => {
<>
{hiddenSessionCount > 0 && (
<button
type="button"
onClick={() => setRenderCount((count) => count + SESSION_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadMoreSessions').replace('{n}', String(hiddenSessionCount))}
</button>
)}
{displayedSessions.map((session) => {
const isActive = session.id === activeSessionId;
const time = new Date(session.updatedAt);
const timeStr = formatRelativeTime(time, t);
@@ -85,7 +107,8 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
</div>
</div>
);
})
})}
</>
)}
</div>
</ScrollArea>

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import type {
@@ -29,7 +29,6 @@ import {
endDraftSend,
tryBeginDraftSend,
} from './ai/draftSendGate';
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import {
buildPromptWithTerminalSelectionAttachments,
@@ -39,8 +38,10 @@ import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
getNetcattyBridge,
isAIChatSessionStreaming,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
@@ -49,7 +50,16 @@ import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
if (props.isVisible ?? true) {
return true;
}
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
return isAIChatSessionStreaming(sessionId);
}
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
draftsByScope,
@@ -134,23 +144,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return sessionIds;
}, [activeSessionIdMap, scopeKey]);
const deferredSessions = useDeferredValue(sessions);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(
session,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
() => getScopedHistorySessions(
deferredSessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
);
const explicitPanelView = panelViewByScope[scopeKey];
@@ -201,16 +204,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [terminalSessions, scopeType, scopeTargetId]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions) {
if (!bridge?.aiMcpUpdateSessions) return;
const timeoutId = window.setTimeout(() => {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [isVisible, terminalSessions, activeSessionId]);
useEffect(() => {
if (!isVisible) return;
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
showDraftView(scopeKey);
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
useEffect(() => {
if (!activeSession) return;
@@ -342,30 +353,27 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncProviders && providers.length > 0) {
void bridge.aiSyncProviders(providers);
}
}, [providers]);
}, [isVisible, providers]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
useEffect(() => {
return () => {
};
}, []);
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
const {
discoveredAgents,
isDiscovering,
rediscover,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
const handleEnableDiscoveredAgent = useCallback(
(agent: DiscoveredAgent) => {
@@ -456,6 +464,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
@@ -478,12 +487,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
});
return () => { cancelled = true; };
}, [isCodexManagedAgent, currentAgentId]);
}, [isVisible, isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!isVisible) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
@@ -526,7 +536,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
@@ -866,8 +876,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
if (!isVisible) return null;
return (
<AIChatPanelContent
t={t}
@@ -919,7 +927,81 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'sessions',
'activeSessionIdMap',
'draftsByScope',
'panelViewByScope',
'setActiveSessionId',
'ensureDraftForScope',
'updateDraft',
'showDraftView',
'showSessionView',
'clearDraftForScope',
'addDraftFiles',
'removeDraftFile',
'createSession',
'deleteSession',
'updateSessionTitle',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
'updateMessageById',
'providers',
'activeProviderId',
'activeModelId',
'defaultAgentId',
'toolIntegrationMode',
'externalAgents',
'setExternalAgents',
'agentModelMap',
'setAgentModel',
'agentProviderMap',
'setAgentProvider',
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
'maxIterations',
'webSearchConfig',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
function aiChatSidePanelPropsAreEqual(
prev: AIChatSidePanelProps,
next: AIChatSidePanelProps,
): boolean {
const prevKeep = shouldKeepAIChatSidePanelMounted(prev);
const nextKeep = shouldKeepAIChatSidePanelMounted(next);
if (!prevKeep && !nextKeep) {
return true;
}
if (prevKeep !== nextKeep) {
return false;
}
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
if (prev[key] !== next[key]) return false;
}
return true;
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
// Keep every mounted AI panel alive — the parent (AIChatPanelsHost) only hides
// inactive tabs via CSS, mirroring the SFTP/Scripts/Theme panels. Returning
// null here used to tear down the whole subtree on each top-tab switch, which
// forced the Streamdown-backed message list to re-parse + re-highlight up to
// 50 messages synchronously on every switch (the source of the jank). Effects
// inside AIChatSidePanelActive are gated by `isVisible`, and re-renders for
// hidden, non-streaming panels are skipped by `aiChatSidePanelPropsAreEqual`,
// so staying mounted is cheap while eliminating the remount cost.
return <AIChatSidePanelActive {...props} />;
}, aiChatSidePanelPropsAreEqual);
AIChatSidePanel.displayName = 'AIChatSidePanel';
export default AIChatSidePanel;

View File

@@ -71,7 +71,8 @@ type DistroAvatarProps = {
host: Host;
fallback: string;
className?: string;
size?: "sm" | "md" | "lg";
/** xs matches top tab bar icons (h-4 rounded rect) */
size?: "xs" | "sm" | "md" | "lg";
};
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
@@ -85,16 +86,18 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
// Size variants - all use rounded corners for consistency
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
const sizeClasses = {
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
lg: "h-14 w-14 rounded-xl",
xs: "h-4 w-4 rounded",
sm: "h-5 w-5 rounded",
md: "h-8 w-8 rounded",
lg: "h-11 w-11 rounded",
};
const iconSizes = {
sm: "h-3.5 w-3.5",
md: "h-5 w-5",
lg: "h-6 w-6",
xs: "h-2.5 w-2.5",
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
const containerClass = sizeClasses[size];
@@ -105,8 +108,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-amber-500/15 text-amber-500",
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
@@ -119,8 +122,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
containerClass,
"flex items-center justify-center overflow-hidden",
bg,
className,
)}
@@ -138,8 +141,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-primary/15 text-primary",
containerClass,
"flex items-center justify-center bg-primary/15 text-primary",
className,
)}
>

View File

@@ -2,10 +2,10 @@
* FileOpenerDialog - Dialog for choosing how to open a file
*/
import { Edit2, FolderOpen } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { getFileExtension, hasFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -26,13 +26,17 @@ const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
}) => {
const { t } = useI18n();
const [isSelectingApp, setIsSelectingApp] = useState(false);
const [rememberChoice, setRememberChoice] = useState(true);
const [rememberChoice, setRememberChoice] = useState(() => hasFileExtension(fileName));
useEffect(() => {
if (open) {
setRememberChoice(hasFileExtension(fileName));
}
}, [open, fileName]);
const extension = getFileExtension(fileName);
// Show edit option for files that are not known binary formats
const canEdit = !isKnownBinaryFile(fileName);
// For files without extension, we use 'file' as virtual extension
// So we always allow setting default (hasExtension is always true)
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {

View File

@@ -1,6 +1,11 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
hostTreeInlineGroupEditStore,
useHostTreeInlineGroupEdit,
} from '../application/state/hostTreeInlineGroupEditStore';
import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActionsStore';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
@@ -8,7 +13,9 @@ import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/
import { cn } from '../lib/utils';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
@@ -26,12 +33,14 @@ interface HostTreeViewProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -54,12 +63,14 @@ interface TreeNodeProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -83,14 +94,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
@@ -99,8 +112,22 @@ const TreeNode: React.FC<TreeNodeProps> = ({
setDragOverDropTarget,
groupConfigs,
}) => {
const { t } = useI18n();
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const commitRename = commitInlineGroupRename ?? vaultTreeActions?.commitInlineGroupRename;
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const isInlineEditing = inlineEdit?.groupPath === node.path;
const groupRowRef = useRef<HTMLDivElement>(null);
const isExpanded = expandedPaths.has(node.path);
useEffect(() => {
if (!isInlineEditing || !inlineEdit?.shouldScrollIntoView) return;
const frame = requestAnimationFrame(() => {
groupRowRef.current?.scrollIntoView({ block: 'nearest' });
hostTreeInlineGroupEditStore.markScrollHandled();
});
return () => cancelAnimationFrame(frame);
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
@@ -149,6 +176,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
ref={groupRowRef}
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
@@ -188,7 +216,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
</div>
<span className="truncate flex-1 font-semibold">{node.name}</span>
{isInlineEditing && commitRename && cancelRename ? (
<HostTreeGroupInlineRenameInput
initialName={inlineEdit.initialName}
onCommit={commitRename}
onCancel={cancelRename}
className="flex-1 font-semibold"
/>
) : (
<span className="truncate flex-1 font-semibold">{node.name}</span>
)}
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
<FileSymlink size={10} />
@@ -212,28 +249,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(node.path)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
</ContextMenuItem>
)}
</ContextMenuContent>
<HostTreeGroupContextMenuContent
groupPath={node.path}
isManaged={isManaged}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onDeleteGroup={onDeleteGroup}
onUnmanageGroup={onUnmanageGroup}
/>
</ContextMenu>
<CollapsibleContent>
@@ -251,14 +274,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
@@ -334,7 +359,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
depth,
onConnect,
onEditHost,
onDuplicateHost,
onDuplicateHost: _onDuplicateHost,
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
@@ -344,7 +369,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
toggleHostSelection,
groupConfigs,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
@@ -390,7 +414,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="xs" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate flex items-center gap-1.5">
@@ -425,26 +449,12 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onCopyCredentials={onCopyCredentials}
onDeleteHost={onDeleteHost}
/>
</ContextMenu>
);
};
@@ -462,14 +472,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
@@ -589,14 +601,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}

View File

@@ -18,10 +18,17 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from './ui/context-menu';
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
return body.length > 0 && !body.includes('/');
};
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
@@ -69,6 +76,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// Normalize the package list + derive ancestor packages implied by each path
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
@@ -87,7 +95,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (s.package) addWithAncestors(s.package);
});
return set;
}, [packages, snippets]);
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
@@ -99,6 +107,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// everything without drilling in. After that, respect the user's collapse
// choices across unrelated refreshes.
useEffect(() => {
if (!isVisible) return;
const seen = seenPackagesRef.current;
const newlySeen: string[] = [];
normalizedPackages.forEach((p) => {
@@ -110,10 +119,53 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (newlySeen.length === 0) return;
setExpandedPaths((prev) => {
const next = new Set(prev);
newlySeen.forEach((p) => next.add(p));
// Only auto-expand root packages on first sight — expanding the full
// tree upfront was freezing the panel on large snippet libraries.
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
return next;
});
}, [normalizedPackages]);
}, [normalizedPackages, isVisible]);
const snippetIndex = useMemo(() => {
if (!isVisible) {
return {
snippetsByPackage: new Map<string, Snippet[]>(),
descendantCountByPackage: new Map<string, number>(),
};
}
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
const bumpCount = (path: string) => {
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
};
for (const snippet of snippets) {
const pkg = snippet.package || '';
const bucket = snippetsByPackage.get(pkg);
if (bucket) bucket.push(snippet);
else snippetsByPackage.set(pkg, [snippet]);
if (pkg === '') {
bumpCount('');
continue;
}
let path = pkg;
while (true) {
bumpCount(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
}
for (const bucket of snippetsByPackage.values()) {
bucket.sort((a, b) => a.label.localeCompare(b.label));
}
return { snippetsByPackage, descendantCountByPackage };
}, [snippets, isVisible]);
const togglePackage = useCallback((path: string) => {
setExpandedPaths((prev) => {
@@ -126,6 +178,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
const q = search.trim().toLowerCase();
if (!q) return null;
return snippets.filter(
@@ -133,9 +186,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
s.label.toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q),
);
}, [snippets, search]);
}, [snippets, search, isVisible]);
const rows = useMemo<TreeRow[]>(() => {
if (!isVisible) return [];
if (searchMatches !== null) return [];
const out: TreeRow[] = [];
@@ -159,15 +213,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
};
const snippetsIn = (pkg: string | null): Snippet[] =>
snippets
.filter((s) => (s.package || '') === (pkg ?? ''))
.sort((a, b) => a.label.localeCompare(b.label));
const countDescendants = (pkg: string): number =>
snippets.filter((s) => {
const sp = s.package || '';
return sp === pkg || sp.startsWith(pkg + '/');
}).length;
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
@@ -181,7 +227,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: countDescendants(pkg),
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
hasChildren,
isExpanded,
});
@@ -200,7 +246,38 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
childPackagesOf(null).forEach((root) => walk(root, 0));
return out;
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
const listItems = useMemo((): ScriptsListItem[] => {
if (!isVisible) return [];
if (searchMatches !== null) {
return searchMatches.map((snippet) => ({
key: `search:${snippet.id}`,
kind: 'search',
snippet,
}));
}
return rows.flatMap((row): ScriptsListItem[] => {
if (row.type === 'package') {
return [{
key: `pkg:${row.id}`,
kind: 'package',
row,
countLabel: t('snippets.package.count', { count: row.count }),
}];
}
return [{
key: `snip:${row.id}`,
kind: 'snippet',
row,
}];
});
}, [rows, searchMatches, t, isVisible]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
@@ -265,62 +342,62 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
{!hasAnyContent && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
)}
{/* Search flat list */}
{searchMatches !== null && searchMatches.length > 0 &&
searchMatches.map((s) => (
<SnippetRow
key={s.id}
snippet={s}
depth={0}
subtitle={s.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(s)}
onEdit={() => handleEditSnippet(s)}
onDelete={() => handleDeleteSnippet(s.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
))}
{/* Tree */}
{searchMatches === null &&
rows.map((row) =>
row.type === 'package' ? (
<PackageRow
key={`pkg:${row.id}`}
row={row}
countLabel={t('snippets.package.count', { count: row.count })}
onToggle={() => togglePackage(row.path)}
/>
) : (
<div className="flex-1 min-h-0">
{!hasAnyContent ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
contentClassName="py-1"
items={listItems}
itemHeight={SCRIPT_ROW_HEIGHT}
getItemKey={(item) => item.key}
renderItem={(item) => {
if (item.kind === 'search') {
return (
<SnippetRow
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
}
if (item.kind === 'package') {
return (
<PackageRow
row={item.row}
countLabel={item.countLabel}
onToggle={() => togglePackage(item.row.path)}
/>
);
}
return (
<SnippetRow
key={`snip:${row.id}`}
snippet={row.snippet}
depth={row.depth}
onClick={() => handleSnippetClick(row.snippet)}
onEdit={() => handleEditSnippet(row.snippet)}
onDelete={() => handleDeleteSnippet(row.snippet.id)}
snippet={item.row.snippet}
depth={item.row.depth}
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
),
)}
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
);
}}
/>
)}
</div>
</div>
</TooltipProvider>
);
@@ -332,7 +409,7 @@ interface PackageRowProps {
onToggle: () => void;
}
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
<button
type="button"
onClick={onToggle}
@@ -351,7 +428,8 @@ const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) =>
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
</button>
);
));
PackageRow.displayName = 'PackageRow';
interface SnippetRowProps {
snippet: Snippet;
@@ -364,7 +442,7 @@ interface SnippetRowProps {
deleteLabel: string;
}
const SnippetRow: React.FC<SnippetRowProps> = ({
const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
@@ -415,7 +493,8 @@ const SnippetRow: React.FC<SnippetRowProps> = ({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
));
SnippetRow.displayName = 'SnippetRow';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -343,7 +343,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-8 w-8 rounded-md"
size="md"
/>
<div className="flex-1 min-w-0">
<Tooltip>

View File

@@ -16,28 +16,49 @@ type AppInfo = {
};
const REPO_URL = "https://github.com/binaricat/Netcatty";
const BUG_REPORT_TEMPLATE = "bug_report.yml";
const buildIssueUrl = (appInfo: AppInfo) => {
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const mapIssuePlatform = (platform?: string) => {
switch (platform) {
case "darwin":
return "macOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
default:
return undefined;
}
};
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
export const buildIssueUrl = (appInfo: AppInfo) => {
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
template: BUG_REPORT_TEMPLATE,
title: "[Bug] ",
});
if (appInfo.version) {
params.set("version", appInfo.version);
}
const platform = mapIssuePlatform(appInfo.platform);
if (platform) {
params.set("platform", platform);
}
const installSource =
appInfo.version === "0.0.0"
? "Built from source (npm run dev / pack)"
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
params.set("install_source", installSource);
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
params.set(
"logs",
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
);
return `${REPO_URL}/issues/new?${params.toString()}`;
};

View File

@@ -325,6 +325,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
/>
)}

View File

@@ -108,15 +108,13 @@ test("clipboard files become path-backed upload entries", () => {
]);
});
test("clipboard upload ignores directories until recursive paste is supported", () => {
test("clipboard upload keeps directories for recursive folder paste", () => {
const files: ClipboardLocalFile[] = [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
];
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
]);
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
});
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {

View File

@@ -10,7 +10,8 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
@@ -39,6 +40,7 @@ import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts"
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
import { shouldFollowTerminalCwdNavigate } from "./sftp/sftpFollowTerminalCwd";
interface SftpSidePanelProps {
hosts: Host[];
@@ -72,7 +74,10 @@ interface SftpSidePanelProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
activeTerminalCwd?: string | null;
sftpFollowTerminalCwd?: boolean;
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
@@ -102,6 +107,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
activeTerminalCwd = null,
sftpFollowTerminalCwd = false,
onSftpFollowTerminalCwdChange,
onRequestTerminalFocus,
terminalSettings,
}) => {
@@ -190,199 +198,34 @@ 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);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
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,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
// Auto-connect when activeHost changes.
// Uses sftpRef to avoid re-triggering on every sftp state change.
const connectedKeyRef = useRef<string | null>(null);
// Store the Host object used for the current connection so the header
// can show session-time overrides even during deferred host switches.
const connectedHostObjRef = useRef<Host | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
const [sftpUiReady, setSftpUiReady] = useState(false);
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
// visibility changes. When the user switches terminal tabs, the panel
// toggles isVisible but should preserve its navigation state (the user may
// have navigated away from initialLocation). When the panel is truly
// closed, the component unmounts and all refs are naturally reset.
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd();
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd]);
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
// Block host-following while any connection-sensitive interactive UI is
// active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
// Note: transfers are NOT included here — they run on their own sftpId
// independent of the active tab, and forceNewTab preserves old connections.
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
const runAutoConnect = useCallback(() => {
if (!activeHost) return;
const s = sftpRef.current;
const hasActiveWork = interactiveWorkActive
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
// Serial terminals don't support SFTP — disconnect any existing
// connection (remote or local) so the panel doesn't remain bound to
// a previous host.
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
// Serial terminals don't support SFTP. Just clear the tracked
// connection key so switching back to a remote terminal will
// trigger auto-connect. Don't disconnect existing tabs — they
// may be reused when focus returns.
connectedKeyRef.current = null;
return;
}
// Local terminals connect to the local file browser
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
if (hasActiveWork) return;
const leftConn = s.leftPane.connection;
if (leftConn?.isLocal) {
// Already connected locally
connectedKeyRef.current = "local";
return;
}
// Check for an existing local tab to reuse
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
tab.connection?.isLocal && tab.connection.status === "connected",
);
@@ -392,27 +235,28 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
connectedKeyRef.current = "local";
// Preserve existing remote tab when switching to local
const needsNewTab = !!(leftConn && leftConn.status === "connected");
if (needsNewTab) {
s.connect("left", "local", { forceNewTab: true });
} else if (leftConn) {
// Await disconnect before connecting locally to avoid the async
// disconnect wiping out the fresh local connection.
void s.disconnect("left").then(() => s.connect("left", "local"));
} else {
s.connect("left", "local");
}
return;
}
// Build a connection key that accounts for session-time overrides
// (same host ID may have different port/protocol in different workspace panes).
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
if (connectedKeyRef.current === connectionKey) return;
// Don't switch connections while transfers or editor are active
const connectionKey = buildCacheKey(
activeHost.id,
activeHost.hostname,
activeHost.port,
activeHost.protocol,
activeHost.sftpSudo,
activeHost.username,
);
if (connectedKeyRef.current === connectionKey) return;
if (hasActiveWork) return;
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
hostLabel: activeHost.label,
@@ -420,14 +264,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hostname: activeHost.hostname,
});
// Check if an existing SFTP tab matches this exact endpoint.
// We track which connectionKey was used to create each tab so that
// tabs for the same host ID with different session-time overrides
// (port/protocol) are not incorrectly reused.
const tabs = s.leftTabs.tabs;
const existingTab = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
// Don't reuse errored tabs — they need a fresh connection
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
});
@@ -438,11 +277,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
// Create a new tab when there's already an active connection, so the
// previous tab is preserved for instant switching on focus change.
// This covers both different hosts AND same host with different
// session-time overrides (port/protocol), preventing the old SFTP
// session from being closed while it may have in-flight transfers.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected");
@@ -455,12 +289,21 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, activeSessionId, hasActiveWork]);
}, [activeHost, activeSessionId, interactiveWorkActive]);
useEffect(() => {
if (!activeHost || !isVisible) return;
let cancelled = false;
const frameId = requestAnimationFrame(() => {
if (!cancelled) runAutoConnect();
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
// so they stop when the session disconnects.
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
@@ -480,8 +323,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
// Include full endpoint key so that same-hostId sessions with
// different overrides each get their initial location applied.
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
@@ -563,6 +404,321 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
t,
]);
return (
<SftpSidePanelDeferredMount ready={sftpUiReady} onReady={() => setSftpUiReady(true)}>
<SftpSidePanelInteractiveBody
hosts={hosts}
hostWriteSource={hostWriteSource}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={onGetTerminalCwd}
activeTerminalCwd={activeTerminalCwd}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
onRequestTerminalFocus={onRequestTerminalFocus}
isVisible={isVisible}
behaviorRef={behaviorRef}
autoSyncRef={autoSyncRef}
connectedHostObjRef={connectedHostObjRef}
connectedKeyRef={connectedKeyRef}
onInteractiveWorkChange={setInteractiveWorkActive}
listSftp={listSftp}
mkdirLocal={mkdirLocal}
deleteLocalFile={deleteLocalFile}
showSaveDialog={showSaveDialog}
selectDirectory={selectDirectory}
startStreamTransfer={startStreamTransfer}
listLocalDir={listLocalDir}
listDrives={listDrives}
openPath={openPath}
t={t}
/>
</SftpSidePanelDeferredMount>
);
};
type SftpSidePanelInteractiveBodyProps = {
hosts: Host[];
hostWriteSource: Host[];
updateHosts: (hosts: Host[]) => void;
sftp: ReturnType<typeof useSftpState>;
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
sftpDefaultViewMode: "list" | "tree";
activeHost: Host | null;
showWorkspaceHostHeader: boolean;
renderOverlays: boolean;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
activeTerminalCwd?: string | null;
sftpFollowTerminalCwd: boolean;
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
onRequestTerminalFocus?: () => void;
isVisible: boolean;
behaviorRef: MutableRefObject<"open" | "transfer">;
autoSyncRef: MutableRefObject<boolean>;
connectedHostObjRef: MutableRefObject<Host | null>;
connectedKeyRef: MutableRefObject<string | null>;
onInteractiveWorkChange: (active: boolean) => void;
listSftp: ReturnType<typeof useSftpBackend>["listSftp"];
mkdirLocal: ReturnType<typeof useSftpBackend>["mkdirLocal"];
deleteLocalFile: ReturnType<typeof useSftpBackend>["deleteLocalFile"];
showSaveDialog: ReturnType<typeof useSftpBackend>["showSaveDialog"];
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
startStreamTransfer: ReturnType<typeof useSftpBackend>["startStreamTransfer"];
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
openPath: ReturnType<typeof useSftpBackend>["openPath"];
t: ReturnType<typeof useI18n>["t"];
};
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
hosts,
hostWriteSource,
updateHosts,
sftp,
sftpRef,
sftpDefaultViewMode,
activeHost,
showWorkspaceHostHeader,
renderOverlays,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
activeTerminalCwd = null,
sftpFollowTerminalCwd,
onSftpFollowTerminalCwdChange,
onRequestTerminalFocus,
isVisible,
behaviorRef,
autoSyncRef,
connectedHostObjRef,
connectedKeyRef,
onInteractiveWorkChange,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
listLocalDir,
listDrives,
openPath,
t,
}) => {
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: hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, [sftpRef]);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, [sftpRef]);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [sftpRef, 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(() => {
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);
};
}, [sftpRef, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
useEffect(() => {
onInteractiveWorkChange(showTextEditor || !!permissionsState || showFileOpenerDialog);
}, [onInteractiveWorkChange, permissionsState, showFileOpenerDialog, showTextEditor]);
const canFollowTerminalCwd = useMemo(() => {
if (!onGetTerminalCwd || !activeHost) return false;
const proto = activeHost.protocol;
if (proto === "local" || proto === "serial") return false;
if (activeHost.id?.startsWith("local-") || activeHost.id?.startsWith("serial-")) return false;
return true;
}, [activeHost, onGetTerminalCwd]);
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd, sftpRef]);
const syncFollowToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd || !sftpFollowTerminalCwd || !canFollowTerminalCwd) {
return;
}
let terminalCwd = activeTerminalCwd;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
}
if (!terminalCwd) return;
const connection = sftpRef.current.leftPane.connection;
if (!shouldFollowTerminalCwdNavigate({
followEnabled: sftpFollowTerminalCwd,
isVisible,
terminalCwd,
currentPath: connection?.currentPath,
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
})) {
return;
}
await sftpRef.current.navigateTo("left", terminalCwd);
}, [
activeTerminalCwd,
canFollowTerminalCwd,
hasActiveWork,
isVisible,
onGetTerminalCwd,
sftpRef,
sftpFollowTerminalCwd,
]);
const handleToggleFollowTerminalCwd = useCallback(() => {
onSftpFollowTerminalCwdChange?.(!sftpFollowTerminalCwd);
}, [onSftpFollowTerminalCwdChange, sftpFollowTerminalCwd]);
useEffect(() => {
if (!sftpFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
void syncFollowToTerminalCwd();
}, [
activeTerminalCwd,
canFollowTerminalCwd,
hasActiveWork,
isVisible,
sftpFollowTerminalCwd,
sftp.leftPane.connection?.currentPath,
sftp.leftPane.connection?.status,
sftp.leftPane.connection?.isLocal,
syncFollowToTerminalCwd,
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(() => {
const connection = sftp.leftPane.connection;
@@ -600,7 +756,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[openPath, t],
[openPath, sftpRef, t],
);
const canRevealTransferTarget = useCallback(
@@ -627,7 +783,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return connection.id === task.targetConnectionId;
},
[sftp.leftPane.connection],
[connectedKeyRef, sftp.leftPane.connection],
);
const canCopyTransferTargetPath = useCallback(
@@ -663,7 +819,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
}
return activeHost;
}, [sftp.leftPane.connection, hosts, activeHost]);
}, [activeHost, connectedHostObjRef, hosts, sftp.leftPane.connection]);
// Determine the active pane to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
@@ -681,12 +837,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
data-section="terminal-sftp-host-header"
>
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}
@@ -728,13 +886,15 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
isPaneFocused={hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
followTerminalCwd={canFollowTerminalCwd ? sftpFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
</div>
);
@@ -807,6 +967,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.updateHosts === next.updateHosts &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
@@ -821,6 +982,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.activeTerminalCwd === next.activeTerminalCwd &&
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path &&

View File

@@ -0,0 +1,38 @@
import React, { startTransition, useEffect } from 'react';
type SftpSidePanelDeferredMountProps = {
children: React.ReactNode;
ready: boolean;
onReady: () => void;
};
export const SftpSidePanelDeferredMount: React.FC<SftpSidePanelDeferredMountProps> = ({
children,
ready,
onReady,
}) => {
useEffect(() => {
if (ready) return;
let cancelled = false;
const frameId = requestAnimationFrame(() => {
if (cancelled) return;
startTransition(() => onReady());
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [ready, onReady]);
if (!ready) {
return (
<div className="absolute inset-0 z-10 flex h-full items-center justify-center bg-background text-xs text-muted-foreground">
Loading
</div>
);
}
return <>{children}</>;
};

View File

@@ -107,12 +107,14 @@ interface SyncStatusButtonProps {
onOpenSettings?: () => void;
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
className?: string;
style?: React.CSSProperties;
}
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
onOpenSettings,
onSyncNow,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
@@ -177,9 +179,10 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
style={style}
>
{getButtonIcon()}

View File

@@ -26,6 +26,7 @@ import {
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
@@ -70,7 +71,6 @@ import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerforma
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 { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
@@ -78,12 +78,12 @@ import { useTerminalEffects } from "./terminal/useTerminalEffects";
import { TerminalView } from "./terminal/TerminalView";
import {
forceSyncRenderAfterResize,
formatNetSpeed,
MAX_CONNECTION_LOG_DATA_CHARS,
shouldHideConnectingDialogForConnectionReuse,
shouldShowTerminalConnectionDialog,
type TerminalProps,
} from "./terminal/terminalHelpers";
import { terminalPropsAreEqual } from "./terminal/terminalMemo";
const TerminalComponent: React.FC<TerminalProps> = ({
host,
@@ -94,6 +94,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
themePreviewId,
knownHosts = [],
isVisible,
paneLayoutKey,
inWorkspace,
isResizing,
isFocusMode,
@@ -123,6 +124,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onAddKnownHost,
onExpandToFocus,
onCommandExecuted,
onCommandSubmitted,
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
@@ -140,6 +142,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sudoAutofillPassword,
onAddSelectionToAI,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferTerminalResize = isResizing || layoutSuppressActive;
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
const { t } = useI18n();
@@ -155,6 +160,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
const isBootActiveRef = useRef(false);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
// Token for an in-flight retry chain. handleRetry sets this to a fresh
@@ -372,6 +378,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
host,
sessionId,
onCommandExecuted,
onCommandSubmitted,
commandBufferRef,
promptLineBreakStateRef,
}, termRef.current);
@@ -447,14 +454,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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,
isConnected: status === 'connected',
isVisible,
});
// Server-stats polling now lives inside <TerminalServerStats> (rendered by
// TerminalView) so its ~5s refresh only re-renders that widget, not the whole
// terminal. We just forward `isSupportedOs` via ctx.
const zmodem = useZmodemTransfer(sessionId);
@@ -607,6 +609,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const teardown = () => {
isBootActiveRef.current = false;
retryTokenRef.current = null;
cleanupSession();
xtermRuntimeRef.current?.dispose();
@@ -632,6 +635,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalBackend,
serialConfig,
isVisibleRef,
isBootActiveRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
@@ -858,15 +862,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onAddSelectionToAI?.(sessionId, selection);
}, [onAddSelectionToAI, sessionId]);
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
const handleSetTerminalEncoding = useCallback((encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
userPickedEncodingRef.current = true;
if (sessionRef.current) {
setSessionEncoding(sessionRef.current, encoding);
}
};
}, [setSessionEncoding]);
const handleOpenSFTP = async () => {
const handleOpenSFTP = useCallback(async () => {
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
const initialPath = await resolveSftpInitialPath();
@@ -880,7 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
setShowSFTP(true);
};
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
@@ -1049,7 +1053,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef,
});
const renderControls = (opts?: { showClose?: boolean }) => (
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
host={host}
@@ -1066,7 +1070,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalEncoding={terminalEncoding}
onSetTerminalEncoding={handleSetTerminalEncoding}
/>
);
), [
handleOpenSFTP,
handleSetTerminalEncoding,
handleToggleSearch,
host,
inWorkspace,
isComposeBarOpen,
isSearchOpen,
isWorkspaceComposeBarOpen,
onCloseSession,
onOpenScripts,
onOpenTheme,
onToggleComposeBar,
onUpdateHost,
sessionId,
status,
terminalEncoding,
]);
const statusDotTone =
status === "connected"
@@ -1083,12 +1104,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent);
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);
Terminal.displayName = "Terminal";
export default Terminal;

View File

@@ -5,6 +5,7 @@ import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
const baseProps = {
hosts: [],
customGroups: [],
groupConfigs: [],
proxyProfiles: [],
keys: [],
@@ -28,6 +29,8 @@ const baseProps = {
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
sftpFollowTerminalCwd: false,
setSftpFollowTerminalCwd: () => {},
editorWordWrap: false,
sshDebugLogsEnabled: false,
setEditorWordWrap: () => {},
@@ -39,6 +42,7 @@ const baseProps = {
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
onSplitSession: () => {},
onConnectToHost: () => {},
toggleScriptsSidePanelRef: { current: null },
};

View File

@@ -1,6 +1,6 @@
import { FolderTree, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import {
@@ -22,7 +22,7 @@ import {
} from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, KnownHost, TerminalSession } from '../types';
import { Host, KnownHost, TerminalSession, Workspace } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { resolveHostAutofillPassword } from '../domain/sshAuth';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
@@ -41,16 +41,13 @@ import { resolveScriptsSidePanelShortcutIntent } from '../application/state/reso
import { resolveSidePanelToggleIntent } from '../application/state/resolveSidePanelToggleIntent';
import { resolveAiSidePanelToggleIntent } from '../application/state/resolveAiSidePanelToggleIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
import { useTerminalLayerEffects } from './terminalLayer/useTerminalLayerEffects';
import { TerminalLayerView } from './terminalLayer/TerminalLayerView';
import { useTerminalFocusSidebar } from './terminalLayer/useTerminalFocusSidebar';
import { useTerminalWorkspaceLayout } from './terminalLayer/useTerminalWorkspaceLayout';
import { useTerminalThemePanelState } from './terminalLayer/useTerminalThemePanelState';
import { useTerminalAiContexts } from './terminalLayer/useTerminalAiContexts';
import { resolvePreferredTerminalCwd } from './terminal/sftpCwd';
import { TerminalLayerTabBridge } from './terminalLayer/TerminalLayerTabBridge';
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
ChunkedEscapeFilter,
@@ -66,8 +63,19 @@ import {
type TerminalLayerProps,
} from './terminalLayer/TerminalLayerSupport';
const addMountedSidePanelTabId = (
tabIds: string[],
tabId: string,
): string[] => (tabIds.includes(tabId) ? tabIds : [...tabIds, tabId]);
const removeMountedSidePanelTabId = (
tabIds: string[],
tabId: string,
): string[] => tabIds.filter((id) => id !== tabId);
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
customGroups,
groupConfigs,
proxyProfiles,
keys,
@@ -108,6 +116,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSetWorkspaceFocusedSession,
onReorderWorkspaceSessions,
onSplitSession,
onConnectToHost,
onCreateLocalTerminal,
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
@@ -117,6 +127,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -128,12 +140,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
toggleSidePanelRef,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const isVisible = (!isVaultActive && !isSftpActive) || !!draggingSessionId;
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
const stableRef = useRef<Record<string, unknown>>({});
const activeTabIdRef = useRef(activeTabStore.getActiveTabId());
const activeWorkspaceRef = useRef<Workspace | undefined>(undefined);
const activeSessionRef = useRef<TerminalSession | undefined>(undefined);
const focusedSessionIdRef = useRef<string | undefined>(undefined);
const terminalCwdRevisionRef = useRef(0);
const [terminalCwdRevision, setTerminalCwdRevision] = useState(0);
const cwdProbeCancelersRef = useRef<Map<string, () => void>>(new Map());
const cwdProbeGenerationRef = useRef<Map<string, number>>(new Map());
const handleTerminalCwdChange = useCallback((sessionId: string, cwd: string | null) => {
if (cwd && cwd.trim().length > 0) {
@@ -141,6 +157,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
} else {
terminalRendererCwdBySessionRef.current.delete(sessionId);
}
terminalCwdRevisionRef.current += 1;
setTerminalCwdRevision(terminalCwdRevisionRef.current);
}, []);
// Stable callback references for Terminal components
@@ -150,6 +168,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
@@ -222,10 +242,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onAddKnownHost?.(knownHost);
}, [onAddKnownHost]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
}, [onCommandExecuted]);
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
onTerminalDataCapture?.(sessionId, data);
}, [onTerminalDataCapture]);
@@ -248,43 +264,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const activeWorkspace = useMemo(() => activeTabId ? workspaceById.get(activeTabId) : undefined, [workspaceById, activeTabId]);
const activeSession = useMemo(() => sessions.find(s => s.id === activeTabId), [sessions, activeTabId]);
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const {
activeResizers,
computeSplitHint,
dropHint,
findSplitNode,
handleWorkspaceDrop,
resizing,
setDropHint,
setResizing,
setWorkspaceArea,
workspaceInnerRef,
workspaceOuterRef,
workspaceOverlayRef,
workspaceRectsById,
} = useTerminalWorkspaceLayout({
activeSession,
activeWorkspace,
isFocusMode,
onAddSessionToWorkspace,
onCreateWorkspaceFromSessions,
onSetDraggingSessionId,
sessions,
workspaces,
});
// Workspace-level compose bar state
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const activeTabIdRef = useRef(activeTabId);
activeTabIdRef.current = activeTabId;
const activeWorkspaceRef = useRef(activeWorkspace);
activeWorkspaceRef.current = activeWorkspace;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
@@ -310,6 +292,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
// Keep AI/scripts/theme panels mounted while switching sub-tabs (like SFTP).
const [aiMountedTabIds, setAiMountedTabIds] = useState<string[]>([]);
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
);
@@ -318,7 +304,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
const sftpResizingRef = useRef(false);
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
@@ -326,12 +311,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// restore it after a close. Overwritten on open, never cleared on close.
const lastSidePanelTabRef = useRef<Map<string, SidePanelTab>>(new Map());
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
@@ -454,36 +433,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
});
}, []);
// Side panel resize handler
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
sftpResizingRef.current = true;
const startX = e.clientX;
const startWidth = sidePanelWidth;
let lastWidth = startWidth;
let rafId: number | null = null;
const onMouseMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
setSidePanelWidth(lastWidth);
});
};
const onMouseUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
setSidePanelWidth(lastWidth);
sftpResizingRef.current = false;
persistSidePanelWidth(lastWidth);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [sidePanelWidth, sidePanelPosition, setSidePanelWidth, persistSidePanelWidth]);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
@@ -604,6 +553,56 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionHostsMapRef = useRef(sessionHostsMap);
sessionHostsMapRef.current = sessionHostsMap;
const handleCommandSubmitted = useCallback((_command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
const tabId = activeTabIdRef.current;
if (!sftpFollowTerminalCwdRef.current || !tabId || sidePanelOpenTabsRef.current.get(tabId) !== 'sftp') return;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return;
const revisionAtCommand = terminalCwdRevisionRef.current;
const probeGeneration = (cwdProbeGenerationRef.current.get(sessionId) ?? 0) + 1;
cwdProbeGenerationRef.current.set(sessionId, probeGeneration);
cwdProbeCancelersRef.current.get(sessionId)?.();
const cancelProbe = scheduleBackendCwdProbeAfterCommand({
sessionId,
cwdRevisionAtCommand: revisionAtCommand,
getCwdRevision: () => terminalCwdRevisionRef.current,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
canProbe: async () => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
const host = sessionHostsMapRef.current.get(sessionId);
if (!host) return false;
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId);
return shouldProbeSessionCwd({
isNetworkDevice,
remoteSshVersion: info?.remoteSshVersion,
});
},
onProbedCwd: (cwd) => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return;
const existing = terminalRendererCwdBySessionRef.current.get(sessionId);
if (existing === cwd) return;
handleTerminalCwdChange(sessionId, cwd);
},
});
cwdProbeCancelersRef.current.set(sessionId, cancelProbe);
}, [handleTerminalCwdChange, terminalBackend]);
const handleCommandExecuted = useCallback((command: string, hostId: string, hostLabel: string, sessionId: string) => {
onCommandExecuted?.(command, hostId, hostLabel, sessionId);
}, [onCommandExecuted]);
useEffect(() => () => {
for (const cancel of cwdProbeCancelersRef.current.values()) {
cancel();
}
cwdProbeCancelersRef.current.clear();
}, []);
const sessionSudoAutofillPasswordsMap = useMemo(() => {
const map = new Map<string, string | undefined>();
for (const session of sessions) {
@@ -663,78 +662,52 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
const isTerminalLayerVisible = isVisible || !!draggingSessionId;
const focusedSessionId = activeWorkspace?.focusedSessionId;
const focusedSessionIdRef = useRef(focusedSessionId);
focusedSessionIdRef.current = focusedSessionId;
// Resolve the SFTP host for the current tab.
// Uses the stored host from when the user opened SFTP, but updates when
// the focused session changes in workspace mode.
const sftpActiveHost = useMemo((): Host | null => {
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
// For workspace: follow focus
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
const activeTerminalSessionIdForSftp = useMemo((): string | null => {
if (!isSftpOpenForCurrentTab || !sftpActiveHost) return null;
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
if (!sessionId) return null;
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session || !canReuseTerminalConnection(session)) return null;
const sessionHost = sessionHostsMap.get(session.id);
if (!sessionHost) return null;
const sameEndpoint =
sessionHost.hostname === sftpActiveHost.hostname &&
(sessionHost.port || 22) === (sftpActiveHost.port || 22) &&
(sessionHost.username || "root") === (sftpActiveHost.username || "root");
return sameEndpoint ? session.id : null;
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
);
const mountedAiTabIds = useMemo(
() =>
Array.from(sidePanelOpenTabs.entries())
.filter(([, panel]) => panel === 'ai')
.map(([tabId]) => tabId),
[sidePanelOpenTabs],
);
const markSidePanelSubTabOpened = useCallback((tabId: string, panel: SidePanelTab) => {
if (panel === 'ai') {
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'scripts') {
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
}, []);
const getActiveTerminalSessionId = useCallback((): string | null => {
const activeWorkspace = activeWorkspaceRef.current;
const activeSession = activeSessionRef.current;
if (!activeWorkspace) return activeSession?.id ?? null;
const workspaceSessionIdSet = new Set(collectSessionIds(activeWorkspace.root));
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId && workspaceSessionIdSet.has(focusedSessionId) && sessions.some((session) => session.id === focusedSessionId)) {
return focusedSessionId;
const focusedId = activeWorkspace.focusedSessionId;
if (focusedId && workspaceSessionIdSet.has(focusedId) && sessionsRef.current.some((session) => session.id === focusedId)) {
return focusedId;
}
return sessions.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
}, [activeWorkspace, activeSession?.id, sessions]);
return sessionsRef.current.find((session) => workspaceSessionIdSet.has(session.id))?.id ?? null;
}, []);
const syncWorkspaceFocusIfNeeded = useCallback((sessionId: string | null) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace || !sessionId || activeWorkspace.focusedSessionId === sessionId) return;
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
}, [activeWorkspace, onSetWorkspaceFocusedSession]);
}, [onSetWorkspaceFocusedSession]);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
const getTerminalCwd = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
sessionId,
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
preferFreshBackend: options?.preferFreshBackend,
});
}, [getActiveTerminalSessionId, terminalBackend]);
@@ -750,6 +723,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
const activeTabId = activeTabIdRef.current;
if (!activeTabId) return;
const sessionIdToRefocus = getActiveTerminalSessionId();
syncWorkspaceFocusIfNeeded(sessionIdToRefocus);
@@ -775,8 +749,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(activeTabId);
return next;
});
setAiMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setScriptsMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
setThemeMountedTabIds((prev) => removeMountedSidePanelTabId(prev, activeTabId));
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
}, [getActiveTerminalSessionId, refocusTerminalSession, syncWorkspaceFocusIfNeeded]);
// Resolve the SFTP host for a tab: a previously-stored host, otherwise the
// host of the workspace's focused session or the active session. null = none.
@@ -820,12 +797,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// so the SftpSidePanel stays mounted (hidden) and preserves connections.
// SFTP state is only cleaned up when the panel is fully closed.
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, tab);
return next;
markSidePanelSubTabOpened(tabId, tab);
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, tab);
return next;
});
});
}, [resolveSftpHostForTab]);
}, [markSidePanelSubTabOpened, resolveSftpHostForTab]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
@@ -850,12 +830,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'scripts');
return next;
markSidePanelSubTabOpened(tabId, 'scripts');
startTransition(() => {
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'scripts');
return next;
});
});
}, [handleCloseSidePanel]);
}, [handleCloseSidePanel, markSidePanelSubTabOpened]);
// Toggle the whole side panel (new ⌘/Ctrl+\ shortcut). Close if open; if
// closed, reopen the tab's last sub-panel, defaulting to SFTP (when a host is
@@ -936,7 +919,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id;
if (!sessionId) return;
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
@@ -951,105 +934,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
}, [terminalBackend]);
const handleSnippetFromPanel = useCallback(async (snippet: Snippet) => {
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
}, [handleSnippetClickForFocusedSession]);
const {
activeTopTabsThemeId,
appliedPreviewSessionRef,
applyTerminalPreviewVars,
applyTopTabsPreviewVars,
composeBarThemeColors,
focusedFontFamilyId,
focusedFontFamilyOverridden,
focusedFontSize,
focusedFontSizeOverridden,
focusedFontWeight,
focusedFontWeightOverridden,
focusedThemeOverridden,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession,
handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession,
previewedOrVisibleThemeId,
previewTargetSessionId,
resolvedPreviewTheme,
setThemePreview,
themeCommitTimerRef,
themePreview,
visibleFocusedThemeId,
} = useTerminalThemePanelState({
accentMode,
activeSession,
activeSidePanelTab,
activeWorkspace,
customAccent,
followAppTerminalTheme,
focusedSessionId,
fontSize,
hostMap,
isVisible,
onUpdateHost,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateTerminalThemeId,
sessionHostsMap,
terminalFontFamilyId,
terminalSettings,
terminalTheme,
});
const { aiContextsByTabId, resolveAIExecutorContext } = useTerminalAiContexts({
hostsRef,
mountedAiTabIds,
sessionHostsMap,
sessions,
sessionsRef,
workspaces,
workspacesRef,
});
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt', timestampsEnabled: sessionLogsTimestampsEnabled }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled],
);
const { renderFocusModeSidebar } = useTerminalFocusSidebar({
activeWorkspace,
focusedSessionId,
isFocusMode,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onSetWorkspaceFocusedSession,
onToggleWorkspaceViewMode,
resolvedPreviewTheme,
sessionHostsMap,
sessions,
t,
});
// Handle compose bar send for workspace mode
const handleComposeSend = useCallback((text: string) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace) return;
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
const focusedSessionId = activeWorkspace.focusedSessionId;
if (broadcastEnabled) {
// Send to all sessions in the workspace
const allSessionIds = sessions
.filter(s => s.workspaceId === activeWorkspace.id)
.map(s => s.id);
const allSessionIds = sessionsRef.current
.filter((session) => session.workspaceId === activeWorkspace.id)
.map((session) => session.id);
for (const sid of allSessionIds) {
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
@@ -1059,9 +962,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}
} else {
// Validate focusedSessionId is a live session, then fallback to first available
const workspaceSessions = sessions.filter(s => s.workspaceId === activeWorkspace.id);
const validFocusedId = focusedSessionId && workspaceSessions.some(s => s.id === focusedSessionId)
const workspaceSessions = sessionsRef.current.filter((session) => session.workspaceId === activeWorkspace.id);
const validFocusedId = focusedSessionId && workspaceSessions.some((session) => session.id === focusedSessionId)
? focusedSessionId
: undefined;
const targetId = validFocusedId ?? workspaceSessions[0]?.id;
@@ -1074,13 +976,190 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}
}
}, [activeWorkspace, focusedSessionId, sessions, terminalBackend, isBroadcastEnabled]);
}, [isBroadcastEnabled, terminalBackend]);
// Track previous focusedSessionId to detect changes
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt', timestampsEnabled: sessionLogsTimestampsEnabled }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled],
);
useTerminalLayerEffects({ activeSidePanelTab, activeTabId, activeTabIdRef, activeTopTabsThemeId, activeWorkspace, activityTrackedSessions, appliedPreviewSessionRef, applyTerminalPreviewVars, applyTopTabsPreviewVars, cancelAnimationFrame, ChunkedEscapeFilter, clearTerminalPreviewVars, clearTimeout, clearTopTabsPreviewVars, document, dropHint, filterTabsMap, focusedSessionId, followAppTerminalTheme, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, Math, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, onUpdateSplitSizes, prevFocusedSessionIdRef, previewTargetSessionId, requestAnimationFrame, ResizeObserver, resizing, sessionActivityStore, sessions, Set, setDropHint, setResizing, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setThemePreview, setTimeout, setupMcpApprovalBridge, setWorkspaceArea, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, terminalRendererCwdBySessionRef, themeCommitTimerRef, themePreview, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, visibleFocusedThemeId, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces });
return <TerminalLayerView ctx={{ accentMode, activeResizers, activeSidePanelTab, activeTabId, activeTerminalSessionIdForSftp, activeWorkspace, AIChatPanelsHost, aiContextsByTabId, AIStateMaintenanceHost, AIStateProvider, Array, Button, cn, composeBarThemeColors, computeSplitHint, customAccent, draggingSessionId, dropHint, editorWordWrap, effectiveHosts, findSplitNode, focusedFontFamilyId, focusedFontFamilyOverridden, focusedFontSize, focusedFontSizeOverridden, focusedFontWeight, focusedFontWeightOverridden, focusedSessionId, focusedThemeOverridden, FolderTree, followAppTerminalTheme, fontSize, getTerminalCwd, handleAddKnownHost, handleAddSelectionToAI, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, handlePendingTerminalSelectionConsumed, handlePendingUploadHandled, handleSessionExit, handleSftpInitialLocationApplied, handleSidePanelResizeStart, handleSnippetClickForFocusedSession, handleSnippetFromPanel, handleSnippetExecutorChange, handleStatusChange, handleTerminalCwdChange, handleTerminalDataCapture, handleTerminalFontSizeChange, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleToggleSftpFromBar, handleToggleWorkspaceComposeBar, handleUpdateHost, handleWorkspaceDrop, hosts, hotkeyScheme, identities, isBroadcastEnabled, isComposeBarOpen, isFocusMode, isSidePanelOpenForCurrentTab, isTerminalLayerVisible, keyBindings, keys, knownHosts, MessageSquare, mountedAiTabIds, mountedSftpTabIds, onHotkeyAction, onSetWorkspaceFocusedSession, onSplitSession, Palette, PanelLeft, PanelRight, pendingTerminalSelectionForAI, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, sessionSudoAutofillPasswordsMap, sessions, setDropHint, setEditorWordWrap, setIsComposeBarOpen, setResizing, setSidePanelPosition, sftpActiveHost, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpInitialLocationForTab, sftpPendingUploadsForTab, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, sidePanelPosition, sidePanelWidth, snippetPackages, snippets, splitHorizontalHandlersRef, splitVerticalHandlersRef, sshDebugLogsEnabled, t, TerminalComposeBar, terminalFontFamilyId, TerminalPanesHost, terminalSettings, terminalTheme, themePreview, ThemeSidePanel, Tooltip, TooltipContent, TooltipTrigger, updateHosts, validAIScopeTargetIds, workspaceBroadcastHandlersRef, workspaceById, workspaceFocusHandlersRef, workspaceInnerRef, workspaceOuterRef, workspaceOverlayRef, workspaceRectsById, X, Zap }} />;
stableRef.current = {
accentMode,
activityTrackedSessions,
AIChatPanelsHost,
AISidePanelStateRoot,
AIStateMaintenanceHost,
AIStateProvider,
Array,
Button,
ChunkedEscapeFilter,
clearTerminalPreviewVars,
clearTopTabsPreviewVars,
FolderTree,
MessageSquare,
Palette,
PanelLeft,
PanelRight,
cn,
collectSessionIds,
customAccent,
customGroups,
draggingSessionId,
editorWordWrap,
effectiveHosts,
filterTabsMap,
followAppTerminalTheme,
fontSize,
getSessionActivityIdsToClear,
getTerminalCwd,
handleAddKnownHost,
handleAddSelectionToAI,
handleBroadcastInput,
handleCloseSession,
handleCloseSidePanel,
handleCommandExecuted,
handleCommandSubmitted,
handleComposeSend,
handleOpenSftp,
handleOpenScripts,
handleOpenTheme,
handleOpenAI,
handleOsDetected,
handlePendingTerminalSelectionConsumed,
handlePendingUploadHandled,
handleSessionExit,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleSnippetExecutorChange,
handleStatusChange,
handleTerminalCwdChange,
handleTerminalDataCapture,
handleTerminalFontSizeChange,
handleToggleAiFromTopBar,
handleToggleScriptsSidePanel,
handleToggleSidePanel,
handleToggleSftpFromBar,
handleToggleWorkspaceComposeBar,
handleUpdateHost,
hasNotifiableTerminalOutput,
hostMap,
hosts,
hostsRef,
hotkeyScheme,
identities,
isBroadcastEnabled,
isComposeBarOpen,
keyBindings,
keys,
knownHosts,
lastSidePanelTabRef,
mountedAiTabIds: aiMountedTabIds,
mountedSftpTabIds,
scriptsMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onConnectToHost,
onCreateLocalTerminal,
onCreateWorkspaceFromSessions,
onHotkeyAction,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,
onToggleWorkspaceViewMode,
onToggleWorkspaceViewModeRef,
onUpdateHost,
onUpdateSplitSizes,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateTerminalThemeId,
pendingTerminalSelectionForAI,
refocusActiveTerminalSession,
refocusTerminalSession,
resolveSftpHostForTab,
ScriptsSidePanel,
sessionActivityStore,
sessionChainHostsMap,
sessionHostsMap,
sessionLogConfig,
sessionSudoAutofillPasswordsMap,
sessions,
sessionsRef,
setEditorWordWrap,
setIsComposeBarOpen,
setPendingTerminalSelectionForAI,
setAiMountedTabIds,
setScriptsMountedTabIds,
setThemeMountedTabIds,
setSidePanelOpenTabs,
setSidePanelWidth,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpInitialLocationForTab,
setSftpPendingUploadsForTab,
setupMcpApprovalBridge,
sidePanelOpenTabs,
sidePanelPosition,
sidePanelWidth,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpFollowTerminalCwd,
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
shouldMarkSessionActivity,
snippetExecutorsRef,
snippetPackages,
snippets,
splitHorizontalHandlersRef,
splitVerticalHandlersRef,
sshDebugLogsEnabled,
t,
TerminalComposeBar,
TerminalPanesHost,
terminalCwdRevision,
terminalFontFamilyId,
terminalRendererCwdBySessionRef,
terminalSettings,
terminalTheme,
ThemeSidePanel,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
Tooltip,
TooltipContent,
TooltipTrigger,
updateHosts,
X,
Zap,
validAIScopeTargetIds,
validSessionActivityIds,
workspaceBroadcastHandlersRef,
workspaceById,
workspaceFocusHandlersRef,
workspaces,
workspacesRef,
activeTabIdRef,
activeWorkspaceRef,
activeSessionRef,
focusedSessionIdRef,
setSidePanelPosition,
};
return <TerminalLayerTabBridge stableRef={stableRef} />;
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);

View File

@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { computeHostTreeTabGutter } = await import("./TopTabs.tsx");
test("host tree tab gutter fills the remaining sidebar width", () => {
assert.equal(computeHostTreeTabGutter(280, 120), 160);
});
test("host tree tab gutter never goes negative", () => {
assert.equal(computeHostTreeTabGutter(120, 280), 0);
});

View File

@@ -1,32 +1,45 @@
import { Folder, FolderLock, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId } from '../application/state/activeTabStore';
import { Folder, FolderLock, Menu, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import type { EditorTab } from '../application/state/editorTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import {
useTerminalHostTreeLayoutWidth,
useTerminalHostTreeOpen,
useToggleTerminalHostTree,
} from '../application/state/terminalHostTreeStore';
import type { LogView } from '../application/state/logViewState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host, TerminalSession, Workspace } from '../types';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SyncStatusButton } from './SyncStatusButton';
import { WindowOpacityButton } from './WindowOpacityButton';
import {
ActiveTabAutoScroller,
EditorTopTab,
LogViewTopTab,
RootTopTab,
SessionTopTab,
scrollTopTabIntoComfortView,
WindowControls,
WorkspaceTopTab,
} from './top-tabs/TopTabItems';
import { useTopTabLifecycleAnimations } from './top-tabs/useTopTabLifecycleAnimations';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
const emptyTabStyle: React.CSSProperties = {};
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
return Math.max(0, hostTreeLayoutWidth - toggleRight);
}
interface TopTabsProps {
theme: 'dark' | 'light';
followAppTerminalTheme?: boolean;
@@ -49,6 +62,8 @@ interface TopTabsProps {
onOpenQuickSwitcher: () => void;
onToggleTheme: () => void;
onOpenSettings: () => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
@@ -82,6 +97,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onOpenQuickSwitcher,
onToggleTheme,
onOpenSettings,
windowOpacity,
setWindowOpacity,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
@@ -95,6 +112,15 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const sessionActivityMap = useSessionActivityMap();
const isHostTreeOpen = useTerminalHostTreeOpen();
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const toggleHostTree = useToggleTerminalHostTree();
const activeTabId = useActiveTabId();
const { getTabAnimationClass } = useTopTabLifecycleAnimations(orderedTabs);
const [hostTreeTogglePop, setHostTreeTogglePop] = useState(false);
const fixedLeftTabsRef = useRef<HTMLDivElement>(null);
const hostTreeToggleSlotRef = useRef<HTMLDivElement>(null);
const [hostTreeTabGutter, setHostTreeTabGutter] = useState(0);
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
@@ -199,6 +225,59 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return counts;
}, [sessions]);
const hasTerminalOrWorkspaceTabs = sessions.length > 0 || workspaces.length > 0;
const isActiveTerminalOrWorkspaceTab = orphanSessionMap.has(activeTabId) || workspaceMap.has(activeTabId);
const showHostTreeToggle = hasTerminalOrWorkspaceTabs && isActiveTerminalOrWorkspaceTab;
const updateHostTreeTabGutter = useCallback(() => {
if (!showHostTreeToggle || hostTreeLayoutWidth <= 0) {
setHostTreeTabGutter(0);
return;
}
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
const toggleSlot = hostTreeToggleSlotRef.current;
if (!root || !toggleSlot) {
setHostTreeTabGutter(Math.max(0, hostTreeLayoutWidth));
return;
}
const rootLeft = root.getBoundingClientRect().left;
const toggleRight = toggleSlot.getBoundingClientRect().right - rootLeft;
setHostTreeTabGutter(computeHostTreeTabGutter(hostTreeLayoutWidth, toggleRight));
}, [hostTreeLayoutWidth, showHostTreeToggle]);
useLayoutEffect(() => {
updateHostTreeTabGutter();
const rafId = window.requestAnimationFrame(updateHostTreeTabGutter);
const settleTimer = window.setTimeout(updateHostTreeTabGutter, 320);
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
const ro = new ResizeObserver(() => updateHostTreeTabGutter());
if (root) ro.observe(root);
if (fixedLeftTabsRef.current) ro.observe(fixedLeftTabsRef.current);
if (tabsContainerRef.current) ro.observe(tabsContainerRef.current);
if (hostTreeToggleSlotRef.current) ro.observe(hostTreeToggleSlotRef.current);
window.addEventListener('resize', updateHostTreeTabGutter);
return () => {
window.cancelAnimationFrame(rafId);
window.clearTimeout(settleTimer);
ro.disconnect();
window.removeEventListener('resize', updateHostTreeTabGutter);
};
}, [
updateHostTreeTabGutter,
orderedTabs.length,
showSftpTab,
isWindowFullscreen,
showHostTreeToggle,
isHostTreeOpen,
]);
useEffect(() => {
if (!showHostTreeToggle) return;
setHostTreeTogglePop(true);
const timer = window.setTimeout(() => setHostTreeTogglePop(false), 360);
return () => window.clearTimeout(timer);
}, [showHostTreeToggle]);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
@@ -258,6 +337,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setIsDraggingForReorder(false);
}, [dropIndicator, onReorderTabs]);
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
if (!tab || !e.currentTarget.contains(tab)) return;
scrollTopTabIntoComfortView(e.currentTarget, tab, 'smooth');
}, []);
// Pre-compute tab shift styles for all tabs to avoid recalculation during render
const tabShiftStyles = useMemo(() => {
if (!dropIndicator || !isDraggingForReorder || !draggedTabIdRef.current) {
@@ -378,6 +465,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
host={host}
suffix={suffix}
onRequestCloseEditorTab={onRequestCloseEditorTab}
tabAnimationClass={getTabAnimationClass(tabId)}
/>
);
}
@@ -412,6 +500,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCopySessionToNewWindow={onCopySessionToNewWindow}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
/>
);
}
@@ -445,6 +534,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onCloseWorkspace={onCloseWorkspace}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
/>
);
}
@@ -458,6 +548,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
logView={logView}
onCloseLogView={onCloseLogView}
t={t}
tabAnimationClass={getTabAnimationClass(logView.id)}
/>
);
}
@@ -498,12 +589,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div ref={fixedLeftTabsRef} className="flex items-end gap-0 flex-shrink-0 app-drag">
<RootTopTab
tabId="vault"
label="Vaults"
icon={<FolderLock size={14} />}
className="rounded"
compact={showHostTreeToggle}
/>
{showSftpTab && (
<RootTopTab
@@ -511,6 +603,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
label="SFTP"
icon={<Folder size={14} />}
className="rounded-t-md"
compact={showHostTreeToggle}
/>
)}
</div>
@@ -528,49 +621,95 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}
}}
>
{/* Left fade mask */}
{canScrollLeft && (
{hasTerminalOrWorkspaceTabs && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */}
{!hasOverflow && (
ref={hostTreeToggleSlotRef}
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end"
data-visible={showHostTreeToggle ? 'true' : 'false'}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
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}
data-tab-type="host-tree-toggle"
data-state={isHostTreeOpen ? 'active' : 'inactive'}
className={cn(
'h-7 w-7 flex-shrink-0 app-no-drag rounded-none hover:bg-transparent',
hostTreeTogglePop && showHostTreeToggle && 'top-tab-host-tree-toggle-pop',
)}
style={{
color: isHostTreeOpen
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
pointerEvents: showHostTreeToggle ? 'auto' : 'none',
}}
onClick={toggleHostTree}
>
<Plus size={14} />
<Menu size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openQuickSwitcher')}</TooltipContent>
<TooltipContent>
{isHostTreeOpen ? t('terminal.layer.hostTree.collapse') : t('terminal.layer.hostTree.expand')}
</TooltipContent>
</Tooltip>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
{canScrollRight && (
</div>
)}
{showHostTreeToggle && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
className="top-tab-host-tree-gutter flex-shrink-0"
style={{ width: hostTreeTabGutter }}
aria-hidden
/>
)}
<div className="relative min-w-0 flex-1 flex app-drag" style={dragRegionStyle}>
{/* Left fade mask */}
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onClick={handleScrollableTabClick}
>
{renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */}
{!hasOverflow && (
<Tooltip>
<TooltipTrigger asChild>
<Button
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}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openQuickSwitcher')}</TooltipContent>
</Tooltip>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
</div>
</div>
{/* More tabs button - only when overflowing */}
@@ -591,14 +730,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Tooltip>
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
{/* Fixed right controls — utility icons + window controls share one row */}
<div
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7"
style={dragRegionStyle}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
@@ -607,13 +749,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
</Tooltip>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<WindowOpacityButton
windowOpacity={windowOpacity}
setWindowOpacity={setWindowOpacity}
className="h-7 w-7 shrink-0"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<SyncStatusButton
onOpenSettings={onOpenSettings}
onSyncNow={onSyncNow}
className="h-7 w-7 shrink-0"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive && !followAppTerminalTheme}
@@ -623,15 +776,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
</Tooltip>
</div>
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
<div className="self-stretch flex items-center px-2 app-drag" style={dragRegionStyle}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
className="h-7 w-7 shrink-0 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenSettings}
>
@@ -640,11 +790,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</TooltipTrigger>
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
</Tooltip>
{!isMacClient && <WindowControls />}
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0 self-end" />}
</div>
</div>
);
@@ -665,6 +814,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onOpenSettings === next.onOpenSettings &&
prev.windowOpacity === next.windowOpacity &&
prev.setWindowOpacity === next.setWindowOpacity &&
prev.onSyncNow === next.onSyncNow &&
prev.onToggleTheme === next.onToggleTheme &&
prev.followAppTerminalTheme === next.followAppTerminalTheme &&

View File

@@ -87,6 +87,9 @@ import SerialConnectModal from "./SerialConnectModal";
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
import SnippetsManager from "./SnippetsManager";
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
import { HostTreeGroupDeleteDialog } from "./host/HostTreeGroupDeleteDialog";
import { useHostTreeInlineGroupActions } from "./vault/useHostTreeInlineGroupActions";
import { useRegisterVaultHostTreeActions } from "./vault/useRegisterVaultHostTreeActions";
import { Button } from "./ui/button";
import { RippleButton } from "./ui/ripple";
import {
@@ -939,6 +942,40 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
});
const {
startInlineNewGroup,
startInlineRenameGroup,
startInlineDeleteGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
} = useHostTreeInlineGroupActions({
customGroups,
hosts,
managedSources,
onUpdateCustomGroups,
onUpdateHosts,
onUpdateManagedSources,
selectedGroupPath,
setSelectedGroupPath,
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
unnamedGroupLabel: t("vault.groups.unnamed"),
t,
});
useRegisterVaultHostTreeActions({
handleCopyCredentials,
onDeleteHost,
handleUnmanageGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
startInlineNewGroup,
startInlineRenameGroup,
startInlineDeleteGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
});
const isHostsSectionActive = currentSection === "hosts";
const hasHostsSidePanel =
isHostsSectionActive &&
@@ -978,7 +1015,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
observer.observe(el);
return () => observer.disconnect();
}, []);
return <VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, clearHostSelection, ClipboardCopy, Clock, cn, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />;
return (
<>
<HostTreeGroupDeleteDialog
managedGroupPaths={managedGroupPaths}
onConfirmDelete={deleteGroupPath}
/>
<VaultViewLayout ctx={{ Activity, allGroupPaths, allTags, AppLogo, Array, Badge, BookMarked, Boolean, Button, CheckSquare, ChevronDown, cancelInlineGroupEdit, clearHostSelection, ClipboardCopy, Clock, cn, commitInlineGroupRename, connectionLogs, connectSelectedHosts, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, currentSection, customGroups, deleteGroupPath, deleteGroupWithHosts, deleteSelectedHosts, deleteTargetPath, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, displayedGroups, displayedHosts, DistroAvatar, Download, Dropdown, DropdownContent, DropdownTrigger, Edit2, editingGroupPath, editingHost, editingHostGroupDefaults, FileCode, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, Globe, groupConfigs, GroupDetailsPanel, groupedDisplayHosts, handleConnectClick, handleCopyCredentials, handleDeleteTag, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleEditTag, handleExportHosts, handleHostConnect, handleImportFileSelected, handleNewHost, handleProtocolSelect, handleQuickConnect, handleQuickConnectSaveHost, handleSaveGroupConfig, handleSearchKeyDown, handleUnmanageGroup, hasHostsSidePanel, HostDetailsPanel, hostListScrollRef, hosts, HostTreeView, hotkeyScheme, identities, ImportVaultDialog, Input, isDeleteGroupOpen, isGroupPanelOpen, isHostPanelOpen, isHostsSectionActive, isImportOpen, isMultiSelectMode, isNewFolderOpen, isQuickConnectOpen, isRenameGroupOpen, isSearchQuickConnect, isSerialModalOpen, Key, keyBindings, KeychainManager, keys, knownHostsManagerElement, Label, lastPinnedId, LayoutGrid, LazyConnectionLogsManager, LazyProtocolSelectDialog, List, managedGroupPaths, managedSources, moveGroup, moveHostToGroup, Network, newFolderName, newHostGroupPath, onClearUnsavedConnectionLogs, onConnectSerial, onCreateLocalTerminal, onDeleteConnectionLog, onDeleteHost, onImportOrReuseKey, onOpenLogView, onOpenSettings, onRunSnippet, onToggleConnectionLogSaved, onUpdateCustomGroups, onUpdateGroupConfigs, onUpdateHosts, onUpdateIdentities, onUpdateKeys, onUpdateProxyProfiles, onUpdateSnippetPackages, onUpdateSnippets, Pin, pinnedHosts, pinnedRecentIds, Plug, Plus, PortForwarding, protocolSelectHost, proxyProfiles, ProxyProfilesManager, quickConnectTarget, quickConnectWarnings, QuickConnectWizard, recentHosts, renameGroupError, renameGroupName, renameTargetPath, RippleButton, rootRef, sanitizeHost, search, Search, selectedGroupPath, selectedHostIds, selectedTags, SerialConnectModal, SerialHostDetailsPanel, sessionCount, Set, setCurrentSection, setDeleteGroupWithHosts, setDeleteTargetPath, setDragOverDropTarget, setEditingGroupPath, setEditingHost, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsGroupPanelOpen, setIsHostPanelOpen, setIsImportOpen, setIsMultiSelectMode, setIsNewFolderOpen, setIsQuickConnectOpen, setIsRenameGroupOpen, setIsSerialModalOpen, setLastPinnedId, setNewFolderName, setNewHostGroupPath, setProtocolSelectHost, setQuickConnectTarget, setQuickConnectWarnings, setRenameGroupError, setRenameGroupName, setRenameTargetPath, setSearch, setSelectedGroupPath, setSelectedHostIds, setSelectedTags, setSidebarCollapsed, setSidebarWidth, handleSidebarWidthCommit, setSortMode, setTargetParentPath, Settings, setViewMode, shellHistory, shouldHideEmptyRootHostsSection, showRecentHosts, sidebarCollapsed, sidebarWidth, snippetPackages, snippets, SnippetsManager, SortDropdown, sortMode, splitViewGridStyle, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, submitNewFolder, submitRenameGroup, Suspense, t, TagFilterDropdown, targetParentPath, terminalFontSize, terminalSettings, TerminalSquare, terminalThemeId, toggleHostPinned, toggleHostSelection, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, Upload, upsertHostById, Usb, viewMode, visibleDisplayedHosts, X, Zap }} />
</>
);
};
// Only re-render when data props change - isActive is now managed internally via store subscription

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Droplets } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
const OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
interface WindowOpacityButtonProps {
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
className?: string;
style?: React.CSSProperties;
}
export const WindowOpacityButton: React.FC<WindowOpacityButtonProps> = ({
windowOpacity,
setWindowOpacity,
className,
style,
}) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const percent = Math.round(windowOpacity * 100);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-7 w-7 shrink-0 app-no-drag', className)}
style={style}
aria-label={t('topTabs.windowOpacity')}
>
<Droplets
size={16}
className={percent < 100 ? 'opacity-80' : undefined}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('topTabs.windowOpacity')}</TooltipContent>
</Tooltip>
<PopoverContent
className="w-60 p-3 app-no-drag"
align="end"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-2">
<div className="text-sm font-medium">{t('topTabs.windowOpacity')}</div>
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={5}
value={percent}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="flex-1 accent-primary"
/>
<span className="text-xs text-muted-foreground w-9 text-right tabular-nums">
{percent}%
</span>
</div>
<div className="flex items-center gap-1.5">
{OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
'flex-1 px-2 py-1 rounded-md text-xs font-medium transition-colors border',
windowOpacity === preset.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted/50 text-muted-foreground border-border hover:text-foreground',
)}
>
{preset.label}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};
export default WindowOpacityButton;

View File

@@ -7,7 +7,7 @@
*/
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
@@ -34,6 +34,9 @@ interface ChatMessageListProps {
activeSessionId?: string | null;
}
const MESSAGE_RENDER_BATCH = 50;
const MESSAGE_RENDER_STEP = 50;
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
// Track pending approvals from the approval gate
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
@@ -137,9 +140,24 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
dragStart.current = null;
}, []);
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
useEffect(() => {
setRenderedTailCount(MESSAGE_RENDER_BATCH);
}, [activeSessionId]);
const visibleMessages = useMemo(
() => messages.filter((message) => message.role !== 'system'),
[messages],
);
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
const displayedMessages = hiddenMessageCount > 0
? visibleMessages.slice(-renderedTailCount)
: visibleMessages;
const resolvedToolCallIds = new Set(
visibleMessages
displayedMessages
.filter((m) => m.role === 'tool')
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
@@ -147,7 +165,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
// Build maps from toolCallId → toolName / toolArgs for display
const toolCallNames = new Map<string, string>();
const toolCallArgs = new Map<string, Record<string, unknown>>();
for (const m of visibleMessages) {
for (const m of displayedMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
@@ -166,13 +184,22 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
}
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
const lastAssistantMessage = displayedMessages.findLast(m => m.role === 'assistant');
return (
<>
<Conversation className="flex-1">
<ConversationContent className="gap-1.5 px-4 py-2">
{visibleMessages.map((message) => {
{hiddenMessageCount > 0 && (
<button
type="button"
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
</button>
)}
{displayedMessages.map((message) => {
if (message.role === 'tool') {
return (
<React.Fragment key={message.id}>

View File

@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
import {
buildHistoricalToolReplayMaps,
buildHistoricalToolResultReplayText,
buildHistoricalUserReplayContent,
} from "./cattyHistoryReplay.ts";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
test("buildHistoricalUserReplayContent replaces historical image data with a placeholder", () => {
const attachment: ChatMessageAttachment = {
base64Data: "A".repeat(100_000),
mediaType: "image/png",
filename: "screenshot.png",
};
const result = buildHistoricalUserReplayContent("inspect this", [attachment]);
assert.match(result, /inspect this/);
assert.match(result, /Historical image attachment omitted from replay/);
assert.match(result, /filename=screenshot\.png/);
assert.doesNotMatch(result, /AAAAA/);
});
test("buildHistoricalUserReplayContent preserves historical file path metadata", () => {
const content = buildHistoricalUserReplayContent("inspect this file", [{
base64Data: "A".repeat(200),
mediaType: "text/plain",
filename: "deploy.log",
filePath: "/tmp/netcatty/deploy.log",
}]);
assert.match(content, /Historical file attachment omitted from replay/);
assert.match(content, /filename=deploy\.log/);
assert.match(content, /path=\/tmp\/netcatty\/deploy\.log/);
assert.doesNotMatch(content, /AAAAAAAA/);
});
test("buildHistoricalUserReplayContent replaces historical terminal selections with metadata only", () => {
const attachment: ChatMessageAttachment = {
base64Data: "VGhpcyBpcyBhIGxvbmcgdGVybWluYWwgc2VsZWN0aW9u",
mediaType: "text/plain",
filename: "terminal-selection.log",
terminalSelection: true,
previewText: "npm run build failed on vite",
lineCount: 42,
};
const result = buildHistoricalUserReplayContent("", [attachment]);
assert.match(result, /Historical terminal selection omitted from replay/);
assert.match(result, /filename=terminal-selection\.log/);
assert.match(result, /lines=42/);
assert.match(result, /preview=npm run build failed on vite/);
assert.doesNotMatch(result, /long terminal selection/);
});
test("buildHistoricalToolResultReplayText replaces historical terminal output with a replay placeholder", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "terminal_execute",
arguments: { command: "npm run build" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "BUILD ".repeat(20_000),
isError: true,
};
const replay = buildHistoricalToolResultReplayText(result, toolCall);
assert.match(replay, /Historical terminal output omitted from replay/);
assert.match(replay, /command=npm run build/);
assert.match(replay, /status=error/);
assert.doesNotMatch(replay, /BUILD BUILD BUILD/);
});
test("buildHistoricalToolResultReplayText keeps non-terminal tool results intact", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "web_search",
arguments: { query: "Vercel AI SDK" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "search result summary",
};
assert.equal(buildHistoricalToolResultReplayText(result, toolCall), "search result summary");
});
test("buildHistoricalToolResultReplayText can preserve terminal output for 413 retries", () => {
const toolCall: ToolCall = {
id: "call-1",
name: "terminal_execute",
arguments: { command: "npm test" },
};
const result: ToolResult = {
toolCallId: "call-1",
content: "real terminal output",
};
assert.equal(
buildHistoricalToolResultReplayText(result, toolCall, { preserveTerminalOutput: true }),
"real terminal output",
);
});
test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest preceding call", () => {
const messages: ChatMessage[] = [
{
id: "assistant-1",
role: "assistant",
content: "",
timestamp: 1,
toolCalls: [{ id: "call1", name: "url_fetch", arguments: { url: "https://example.com" } }],
},
{
id: "tool-1",
role: "tool",
content: "",
timestamp: 2,
toolResults: [{ toolCallId: "call1", content: "PAGE" }],
},
{
id: "assistant-2",
role: "assistant",
content: "",
timestamp: 3,
toolCalls: [{ id: "call1", name: "terminal_execute", arguments: { command: "cat /tmp/log" } }],
},
{
id: "tool-2",
role: "tool",
content: "",
timestamp: 4,
toolResults: [{ toolCallId: "call1", content: "TERMINAL BYTES" }],
},
];
const maps = buildHistoricalToolReplayMaps(messages);
const secondResult = messages[3].toolResults?.[0];
assert.ok(secondResult);
const pairedCall = maps.toolCallByToolResult.get(secondResult);
assert.equal(pairedCall?.name, "terminal_execute");
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[0])?.has(messages[0].toolCalls![0]), true);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
});

View File

@@ -0,0 +1,138 @@
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
const MAX_TOOL_COMMAND_CHARS = 220;
function truncateInline(value: string, maxChars: number): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
}
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
return `${attachment.base64Data.length} base64 chars`;
}
function formatTerminalSelectionPlaceholder(
attachment: ChatMessageAttachment,
index: number,
): string {
const details = [
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
return `[Historical terminal selection omitted from replay: ${details}]`;
}
function formatAttachmentPlaceholder(
attachment: ChatMessageAttachment,
index: number,
): string {
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
const details = [
attachment.filename ? `filename=${attachment.filename}` : undefined,
attachment.filePath ? `path=${attachment.filePath}` : undefined,
`mediaType=${attachment.mediaType}`,
describeAttachmentSize(attachment),
].filter(Boolean).join(", ");
return `[Historical ${label} attachment omitted from replay: ${details || `attachment-${index + 1}`}]`;
}
export function buildHistoricalUserReplayContent(
content: string,
attachments: ChatMessageAttachment[] = [],
): string {
const placeholders = attachments.map((attachment, index) => (
isTerminalSelectionAttachment(attachment)
? formatTerminalSelectionPlaceholder(attachment, index)
: formatAttachmentPlaceholder(attachment, index)
));
if (!placeholders.length) return content;
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
}
function getToolCommand(toolCall?: ToolCall): string | undefined {
const args = toolCall?.arguments ?? {};
if (typeof args.command === "string") return args.command;
const serialized = JSON.stringify(args);
return serialized && serialized !== "{}" ? serialized : undefined;
}
export function buildHistoricalToolReplayMaps(messages: ChatMessage[]): {
resolvedToolCallsByAssistant: Map<ChatMessage, Set<ToolCall>>;
toolCallByToolResult: Map<ToolResult, ToolCall>;
} {
const resolvedToolCallsByAssistant = new Map<ChatMessage, Set<ToolCall>>();
const toolCallByToolResult = new Map<ToolResult, ToolCall>();
const pendingToolCalls: Array<{ message: ChatMessage; toolCall: ToolCall }> = [];
for (const message of messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
pendingToolCalls.push({ message, toolCall });
}
continue;
}
if (message.role !== "tool" || !message.toolResults?.length) continue;
for (const result of message.toolResults) {
const pendingIndex = findLastIndex(
pendingToolCalls,
({ toolCall }) => toolCall.id === result.toolCallId,
);
if (pendingIndex < 0) continue;
const [paired] = pendingToolCalls.splice(pendingIndex, 1);
toolCallByToolResult.set(result, paired.toolCall);
const resolved = resolvedToolCallsByAssistant.get(paired.message) ?? new Set<ToolCall>();
resolved.add(paired.toolCall);
resolvedToolCallsByAssistant.set(paired.message, resolved);
}
}
return { resolvedToolCallsByAssistant, toolCallByToolResult };
}
function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
for (let index = items.length - 1; index >= 0; index -= 1) {
if (predicate(items[index])) return index;
}
return -1;
}
export function buildHistoricalToolResultReplayText(
result: ToolResult,
toolCall?: ToolCall,
{
preserveTerminalOutput = false,
}: {
preserveTerminalOutput?: boolean;
} = {},
): string {
const toolName = toolCall?.name ?? "unknown";
if (!isTerminalToolName(toolName) || preserveTerminalOutput) {
return result.content;
}
const details = [
`toolCallId=${result.toolCallId}`,
getToolCommand(toolCall) ? `command=${truncateInline(getToolCommand(toolCall) ?? "", MAX_TOOL_COMMAND_CHARS)}` : undefined,
`outputChars=${result.content.length}`,
result.isError ? "status=error" : "status=success",
].filter(Boolean).join(", ");
return `[Historical terminal output omitted from replay: ${details}. Re-run terminal_execute if exact output is needed.]`;
}
function isTerminalToolName(toolName: string): boolean {
return toolName === "terminal" || toolName === "terminal_exec" || toolName === "terminal_execute";
}

View File

@@ -75,7 +75,7 @@ test("buildExternalAgentHistoryMessagesForBridge keeps fallback history availabl
);
});
test("buildExternalAgentHistoryMessages expands terminal selection attachments", () => {
test("buildExternalAgentHistoryMessages replaces historical terminal selection attachments with placeholders", () => {
const terminalSelection = createTerminalSelectionAttachment("docker ps -a\npermission denied");
assert.ok(terminalSelection);
const messages: ChatMessage[] = [
@@ -88,9 +88,9 @@ test("buildExternalAgentHistoryMessages expands terminal selection attachments",
assert.equal(result.length, 1);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /\[Terminal selection:/);
assert.match(result[0].content, /Historical terminal selection omitted from replay/);
assert.match(result[0].content, /docker ps -a/);
assert.match(result[0].content, /permission denied/);
assert.doesNotMatch(result[0].content, /permission denied/);
});
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
@@ -292,12 +292,9 @@ test("buildExternalAgentHistoryMessages still drops one-word filler user message
}
});
test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
// Regression: tool results used to only reach fallback replay via the
// 500-char compact summary. If the user's last interaction produced a
// large tool output (cat/rg/fetched file), any "use that output"-style
// follow-up lost the actual bytes. Now tool messages flow through the
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
test("buildExternalAgentHistoryMessages replaces recent terminal tool output with a replay placeholder", () => {
// Historical terminal output should remain self-describing without
// replaying the actual bytes on every follow-up.
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
const messages: ChatMessage[] = [
message("u1", "user", "cat /etc/hosts"),
@@ -315,16 +312,15 @@ test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (
const result = buildExternalAgentHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Raw-window tool result carries both the [from ...] provenance label
// and the actual bytes (not just the 500-char compact summary).
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
// Confirm we kept enough bytes to exceed the compact-summary cap.
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): \[Historical terminal output omitted from replay/);
assert.match(flat, /outputChars=1500/);
assert.doesNotMatch(flat, /DATA DATA DATA/);
const toolResultIdx = flat.indexOf("Tool result [from terminal");
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
const toolResultChunk = flat.slice(toolResultIdx);
assert.ok(
toolResultChunk.length > 600,
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
toolResultChunk.length < 500,
`expected terminal result placeholder to stay compact, got ${toolResultChunk.length}`,
);
});
@@ -650,8 +646,10 @@ test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly
//
// Extract the two Tool-result lines and match each to its expected
// args. Use non-greedy .*? — the args JSON can contain parentheses.
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/hosts/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/resolv\.conf/);
assert.doesNotMatch(flat, /HOSTS_BYTES/);
assert.doesNotMatch(flat, /RESOLV_BYTES/);
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");

View File

@@ -1,5 +1,5 @@
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import { buildPromptWithTerminalSelectionAttachments } from "../../application/state/terminalSelectionAttachment.ts";
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from "./cattyHistoryReplay.ts";
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
@@ -62,7 +62,7 @@ function isDurableConstraintText(value: string): boolean {
function getUserHistoryContent(message: ChatMessage): string {
if (message.role !== "user") return message.content || "";
return buildPromptWithTerminalSelectionAttachments(
return buildHistoricalUserReplayContent(
message.content || "",
message.attachments ?? [],
);
@@ -127,7 +127,6 @@ function summarizeToolMessage(
if (!message.toolResults?.length) return [];
return message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const content = normalizeWhitespace(result.content || "");
// Same provenance problem as the raw-window path: once a tool result
// lands in the compact section (older than the 6-item raw window),
// its paired assistant tool_call is almost always gone. Without the
@@ -139,7 +138,10 @@ function summarizeToolMessage(
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(normalizeWhitespace(replayContent), MAX_TOOL_SUMMARY_CHARS)}`;
});
}
@@ -247,13 +249,11 @@ function toRawHistoryMessage(
}
if (message.role === "tool" && message.toolResults?.length) {
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
// per message, ~2000). Without this, follow-up turns after stale-session
// recovery would only see the 500-char compact summary in
// summarizeToolMessage, losing the actual bytes the user might reference
// ("use that output", "what did cat show?"). external agent replay only supports user/
// assistant roles, so we flatten to "assistant" — the tool results were
// produced during the assistant's turn.
// Keep recent tool results self-describing while replacing terminal
// output with placeholders, so stale-session recovery doesn't replay
// bulky command output on every follow-up. External agent replay only
// supports user/assistant roles, so we flatten to "assistant" — the
// tool results were produced during the assistant's turn.
//
// Inline the originating tool_call's name+args. Tool calls and their
// results live in separate messages; if the last six raw items start
@@ -266,7 +266,10 @@ function toRawHistoryMessage(
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
: undefined);
return `${prefix}${callLabel} (${result.toolCallId}): ${replayContent}`;
});
return [{
sourceId: message.id,

View File

@@ -21,6 +21,7 @@ import type {
ExternalAgentConfig,
ProviderAdvancedParams,
ProviderConfig,
ToolResult,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
@@ -35,17 +36,30 @@ import {
prepareContextCompaction,
resolveContextWindow,
} from '../../../infrastructure/ai/contextCompaction';
import {
compressMessagesForRequestTooLargeRetry,
} from '../../../infrastructure/ai/requestPayloadCompression';
import {
createCattyRequestTooLargeRetryError,
hadToolProgressBeforeRequestTooLarge,
} from '../../../infrastructure/ai/cattyRequestTooLargeRetry';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAgents';
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { classifyError, isRequestTooLargeError } from '../../../infrastructure/ai/errorClassifier';
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
} from '../../../application/state/terminalSelectionAttachment';
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
import {
buildHistoricalToolReplayMaps,
buildHistoricalToolResultReplayText,
buildHistoricalUserReplayContent,
} from '../cattyHistoryReplay';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
@@ -82,6 +96,11 @@ export type { DefaultTargetSessionHint } from './aiChatStreamingSupport';
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
/** Whether a chat session still has an active stream (used to keep panel mounted while hidden). */
export function isAIChatSessionStreaming(sessionId: string | null | undefined): boolean {
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
}
const OPENAI_CHAT_ASSISTANT_FIELDS = Symbol('netcatty.openAIChatAssistantFields');
type ModelMessageWithOpenAIChatFields = ModelMessage & {
@@ -329,6 +348,7 @@ export function useAIChatStreaming({
// Track the current assistant message ID so updates target the correct message
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
let hadToolProgress = false;
const reader = result.fullStream.getReader();
// -- Text-delta batching: accumulate deltas and flush periodically --
@@ -404,7 +424,16 @@ export function useAIChatStreaming({
try {
while (true) {
const { done, value } = await reader.read();
let readResult: ReadableStreamReadResult<unknown>;
try {
readResult = await reader.read();
} catch (readErr) {
if (isRequestTooLargeError(readErr)) {
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
}
throw readErr;
}
const { done, value } = readResult;
if (done) break;
// Use the StreamChunk union for type narrowing instead of unsafe casts
const chunk = value as StreamChunk;
@@ -471,6 +500,7 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
hadToolProgress = true;
const messageId = ensureAssistantMessage();
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
updateMessageById(streamSessionId, messageId, msg => ({
@@ -496,6 +526,7 @@ export function useAIChatStreaming({
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolResultChunk;
hadToolProgress = true;
// Mark the assistant message's tool execution as completed
updateMessageById(streamSessionId, activeMsgId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
@@ -542,6 +573,14 @@ export function useAIChatStreaming({
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
break;
}
if (isRequestTooLargeError(typedChunk.error)) {
cancelPendingFlush();
flushText();
throw createCattyRequestTooLargeRetryError(
typedChunk.error,
hadToolProgress,
);
}
cancelPendingFlush();
flushText();
updateMessageById(streamSessionId, activeMsgId, msg => ({
@@ -774,73 +813,86 @@ export function useAIChatStreaming({
};
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
let openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const buildSdkMessages = (
allMessages: ChatMessage[],
includeCurrentUserMessage: boolean,
{
preserveTerminalToolResults = new Set<ToolResult>(),
}: {
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
} = {},
): Array<ModelMessage> => {
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
const nextFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
const sdkMessages: Array<ModelMessage> = [];
let previousHistoryMessageWasToolResult = false;
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
let previousHistoryMessageWasToolResult = false;
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
const modelText = messageAttachments?.length
? buildPromptWithTerminalSelectionAttachments(m.content, messageAttachments)
: m.content;
const modelAttachments = messageAttachments?.filter(
(attachment) => !isTerminalSelectionAttachment(attachment),
);
if (modelAttachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: modelText });
for (const att of modelAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
for (const m of allMessages) {
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
if (m.role === 'user') {
// Historical attachments are replayed as placeholders so screenshots,
// files, and terminal selections do not balloon every follow-up request.
const messageAttachments = m.attachments ?? m.images;
sdkMessages.push({
role: 'user',
content: buildHistoricalUserReplayContent(m.content, messageAttachments ?? []),
});
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedToolCalls = resolvedToolCallsByAssistant.get(m);
const resolvedCalls = resolvedToolCalls
? m.toolCalls.filter(tc => resolvedToolCalls.has(tc))
: [];
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: modelText });
}
} else if (m.role === 'assistant') {
const activeContinuation = isProviderContinuationForSource(
m.providerContinuation,
continuationContext.source,
)
? m.providerContinuation
: undefined;
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
m,
continuationContext.source,
);
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: AssistantContentPart[] = [];
if (resolvedCalls.length > 0) {
if (m.content) {
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
sdkMessages.push(message);
if (resolvedCalls.length > 0) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.content) {
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
@@ -849,84 +901,91 @@ export function useAIChatStreaming({
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
}
if (m.content) {
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
});
}
for (const tc of resolvedCalls) {
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
...(providerOptions ? { providerOptions } : {}),
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
};
sdkMessages.push(message);
if (resolvedCalls.length > 0) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
if (currentMessageFollowsToolResult) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
}
}
} else if (m.content) {
const contentParts: AssistantContentPart[] = [];
for (const part of activeContinuation?.reasoningParts ?? []) {
if (!part.text && !part.providerOptions) continue;
contentParts.push({
type: 'reasoning' as const,
text: part.text,
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
});
}
contentParts.push({
type: 'text' as const,
text: m.content,
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => {
const toolCall = toolCallByToolResult.get(tr);
return {
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: toolCall?.name ?? 'unknown',
output: {
type: 'text' as const,
value: buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
}),
},
};
}),
});
const message: ModelMessage = {
role: 'assistant',
content: toAssistantModelContent(contentParts),
};
sdkMessages.push(message);
if (currentMessageFollowsToolResult) {
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
if (includeCurrentUserMessage) {
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter(
(attachment) => !isTerminalSelectionAttachment(attachment),
);
if (!modelAttachments.length) {
sdkMessages.push({ role: 'user', content: modelText });
} else {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: modelText });
for (const att of modelAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
}
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: findToolName(tr.toolCallId),
output: { type: 'text' as const, value: tr.content },
})),
});
}
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
}
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: trimmed });
for (const att of attachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
sdkMessages.push({ role: 'user', content: trimmed });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
openAIChatAssistantFieldsByMessage = nextFieldsByMessage;
return sdkMessages;
};
const sdkMessages = buildSdkMessages(currentSession?.messages ?? [], true);
const collectToolResultsAfterMessage = (
messages: ChatMessage[],
messageId: string,
): Set<ToolResult> => {
const results = new Set<ToolResult>();
let afterMessage = false;
for (const message of messages) {
if (message.id === messageId) {
afterMessage = true;
continue;
}
if (!afterMessage || message.role !== 'tool' || !message.toolResults?.length) continue;
for (const result of message.toolResults) {
results.add(result);
}
}
return results;
};
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
@@ -954,63 +1013,178 @@ export function useAIChatStreaming({
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
});
const outputReserveTokens = Math.min(4096, Math.ceil(contextWindow * 0.05));
const requestReserveTokens = outputReserveTokens + estimateUnknownTokens({
const getRequestReserveTokens = () => outputReserveTokens + estimateUnknownTokens({
systemPrompt,
toolNames: Object.keys(tools),
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
});
let messagesForStream = sdkMessages;
try {
const compacted = await prepareContextCompaction({
messages: sdkMessages,
contextWindow,
reservedTokens: requestReserveTokens,
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
summarize: async (messagesToSummarize) => {
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
const result = await generateText({
model,
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
}],
abortSignal: abortController.signal,
maxOutputTokens: 1600,
temperature: 0,
});
return result.text;
},
const summarizeForCompaction = async (messagesToSummarize: ModelMessage[]) => {
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
const result = await generateText({
model,
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
messages: [{
role: 'user',
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
}],
abortSignal: abortController.signal,
maxOutputTokens: 1600,
temperature: 0,
});
messagesForStream = compacted.messages;
} catch (err) {
if (abortController.signal.aborted) throw err;
console.warn('[Catty] Context compaction failed; falling back to recent messages only:', err);
messagesForStream = keepRecentContextMessages(sdkMessages, DEFAULT_PROTECT_RECENT_MESSAGES);
}
return result.text;
};
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
const pruned = pruneMessages({
messages,
reasoning: 'all',
emptyMessages: 'remove',
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
pruned,
openAIChatAssistantFieldsByMessage,
);
return pruned;
};
const compactMessages = async (
messages: ModelMessage[],
{
force = false,
statusText,
fallbackLog,
compressForRequestTooLargeRetry = false,
compressionLog,
}: {
force?: boolean;
statusText?: string;
fallbackLog: string;
compressForRequestTooLargeRetry?: boolean;
compressionLog?: string;
},
): Promise<ModelMessage[]> => {
const compressRetryMessages = (candidateMessages: ModelMessage[], log?: string): ModelMessage[] => {
if (!compressForRequestTooLargeRetry) return candidateMessages;
const compressed = compressMessagesForRequestTooLargeRetry(candidateMessages);
if (compressed.didAdjust && log) {
console.warn(log);
}
return compressed.messages;
};
messagesForStream = pruneMessages({
messages: messagesForStream,
reasoning: 'all',
emptyMessages: 'remove',
try {
if (statusText) {
updateLastMessage(sessionId, msg => ({ ...msg, statusText }));
}
const inputMessages = compressRetryMessages(messages, compressionLog);
const compacted = await prepareContextCompaction({
messages: inputMessages,
contextWindow,
reservedTokens: getRequestReserveTokens(),
thresholdRatio: force ? 0 : undefined,
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
summarize: summarizeForCompaction,
});
let nextMessages = force && !compacted.didCompact
? keepRecentContextMessages(inputMessages, DEFAULT_PROTECT_RECENT_MESSAGES)
: compacted.messages;
return compressRetryMessages(nextMessages);
} catch (err) {
if (abortController.signal.aborted) throw err;
console.warn(fallbackLog, err);
const fallbackMessages = keepRecentContextMessages(messages, DEFAULT_PROTECT_RECENT_MESSAGES);
if (!compressForRequestTooLargeRetry) {
return fallbackMessages;
}
const compressed = compressMessagesForRequestTooLargeRetry(fallbackMessages);
if (compressed.didAdjust) {
console.warn('[Catty] Request content compressed after compaction fallback.');
}
return compressed.messages;
}
};
let messagesForStream = sdkMessages;
messagesForStream = await compactMessages(messagesForStream, {
fallbackLog: '[Catty] Context compaction failed; falling back to recent messages only:',
});
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
messagesForStream,
openAIChatAssistantFieldsByMessage,
);
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
messagesForStream,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
messagesForStream = prepareMessagesForStream(messagesForStream);
try {
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
messagesForStream,
abortController.signal,
assistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
} catch (streamErr) {
if (abortController.signal.aborted || !isRequestTooLargeError(streamErr)) {
throw streamErr;
}
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
const statusText = 'Request was too large. Compacting context and retrying...';
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
let retryBaseMessages = messagesForStream;
let retryAssistantMsgId = assistantMsgId;
if (hadToolProgress) {
const latestSession = latestAISessionsSnapshot?.find(session => session.id === sessionId);
if (latestSession) {
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
preserveTerminalToolResults: collectToolResultsAfterMessage(
latestSession.messages,
assistantMsgId,
),
});
}
retryAssistantMsgId = generateId();
addMessageToSession(sessionId, {
id: retryAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
model: activeModelId || context.activeProvider?.defaultModel || '',
providerId: context.activeProvider?.providerId,
statusText,
});
} else {
updateMessageById(sessionId, assistantMsgId, msg => ({
...msg,
content: '',
thinking: undefined,
thinkingDurationMs: undefined,
providerContinuation: undefined,
toolCalls: undefined,
errorInfo: undefined,
executionStatus: undefined,
pendingApproval: undefined,
statusText,
}));
}
const retryMessages = prepareMessagesForStream(await compactMessages(retryBaseMessages, {
force: true,
statusText,
fallbackLog: '[Catty] Forced context compaction after 413 failed; falling back to recent messages only:',
compressForRequestTooLargeRetry: true,
compressionLog: '[Catty] Request content compressed after forced context compaction.',
}));
await processCattyStream(
sessionId,
model,
systemPrompt,
tools,
retryMessages,
abortController.signal,
retryAssistantMsgId,
context.activeProvider?.advancedParams,
continuationContext,
);
}
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);
@@ -1023,7 +1197,7 @@ export function useAIChatStreaming({
}
}, [
processCattyStream, reportStreamError, setStreamingForScope,
updateLastMessage,
addMessageToSession, updateLastMessage, updateMessageById,
]);
return {

View File

@@ -0,0 +1,59 @@
import type { AISession } from '../../infrastructure/ai/types';
import { getSessionScopeMatchRank } from './sessionScopeMatch';
type HistoryCacheKey = string;
const historyCache = new WeakMap<AISession[], Map<HistoryCacheKey, AISession[]>>();
function buildHistoryCacheKey(
scopeType: 'terminal' | 'workspace',
scopeTargetId: string | undefined,
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
): HistoryCacheKey {
const hostKey = scopeHostIds?.join(',') ?? '';
const terminalKey = [...activeTerminalSessionIds].sort().join(',');
return `${scopeType}:${scopeTargetId ?? ''}:${hostKey}:${terminalKey}`;
}
export function getScopedHistorySessions(
sessions: AISession[],
scopeType: 'terminal' | 'workspace',
scopeTargetId: string | undefined,
scopeHostIds: string[] | undefined,
activeTerminalSessionIds: Set<string>,
): AISession[] {
let scopeCache = historyCache.get(sessions);
if (!scopeCache) {
scopeCache = new Map();
historyCache.set(sessions, scopeCache);
}
const cacheKey = buildHistoryCacheKey(
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
);
const cached = scopeCache.get(cacheKey);
if (cached) {
return cached;
}
const result = sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(
session,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session);
scopeCache.set(cacheKey, result);
return result;
}

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
const root = new URL("..", import.meta.url);
function readProjectFile(path: string): string {
return readFileSync(join(root.pathname, path), "utf8");
}
test("terminal side panel exposes stable custom CSS regions", () => {
const source = readProjectFile("components/terminalLayer/TerminalLayerSidePanelSection.tsx");
assert.match(source, /terminal-side-panel-shell/);
assert.match(source, /terminal-side-panel-tabs/);
assert.match(source, /terminal-side-panel-content/);
assert.match(source, /terminal-side-panel-resizer/);
assert.match(source, /isSidePanelOpenForCurrentTab \? 'terminal-side-panel' : undefined/);
});
test("SFTP panel exposes stable custom CSS regions", () => {
const source = [
readProjectFile("components/SftpSidePanel.tsx"),
readProjectFile("components/sftp/SftpPaneView.tsx"),
readProjectFile("components/sftp/SftpPaneToolbar.tsx"),
readProjectFile("components/sftp/SftpPaneFileList.tsx"),
readProjectFile("components/sftp/SftpFileRow.tsx"),
readProjectFile("components/sftp/SftpPaneTreeView.tsx"),
readProjectFile("components/sftp/SftpPaneTreeNode.tsx"),
readProjectFile("components/sftp/SftpTransferQueue.tsx"),
].join("\n");
[
"terminal-sftp-panel",
"terminal-sftp-host-header",
"terminal-sftp-pane",
"terminal-sftp-toolbar",
"terminal-sftp-path",
"terminal-sftp-filter-bar",
"terminal-sftp-list",
"terminal-sftp-list-header",
"terminal-sftp-list-row",
"terminal-sftp-tree",
"terminal-sftp-tree-row",
"terminal-sftp-transfer-queue",
"terminal-sftp-transfer-queue-header",
"terminal-sftp-transfer-list",
].forEach((hook) => assert.match(source, new RegExp(hook)));
});
test("terminal host tree exposes stable custom CSS regions", () => {
const source = readProjectFile("components/terminalLayer/TerminalHostTreeSidebar.tsx");
assert.match(source, /terminal-host-tree-sidebar-shell/);
assert.match(source, /terminal-host-tree-sidebar/);
assert.match(source, /terminal-host-tree-sidebar-content/);
});
test("custom CSS help lists the expanded terminal and SFTP hooks", () => {
const source = readProjectFile("application/i18n/locales/zh-CN/core.ts");
[
"terminal-side-panel-tabs",
"terminal-side-panel-content",
"terminal-sftp-toolbar",
"terminal-sftp-list-row",
"terminal-sftp-tree-row",
"terminal-sftp-transfer-queue",
"terminal-host-tree-sidebar-content",
].forEach((hook) => assert.match(source, new RegExp(hook)));
});

View File

@@ -10,6 +10,7 @@ import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { saveEditorTab } from '../../application/state/editorTabSave';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import { useIsEditorTabActive } from '../../application/state/activeTabStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
import { toast } from '../ui/toast';
@@ -17,8 +18,6 @@ import { TextEditorPane } from './TextEditorPane';
export interface TextEditorTabViewProps {
tabId: EditorTabId;
/** When false the view is hidden via display:none so the Monaco instance persists. */
isVisible: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
@@ -30,7 +29,6 @@ export interface TextEditorTabViewProps {
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
tabId,
isVisible,
hotkeyScheme,
keyBindings,
hostById,
@@ -38,6 +36,9 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
}) => {
const { t } = useI18n();
const tab = useEditorTab(tabId);
// Self-subscribe visibility so switching tabs only re-renders this editor
// instance, not AppView/App.
const isVisible = useIsEditorTabActive(tabId);
const handleContentChange = useCallback(
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {

View File

@@ -0,0 +1,84 @@
import { FileSymlink, Folder, FolderOpen, Monitor, Server } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { sanitizeHost } from '../../domain/host';
import type { Host } from '../../types';
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
export interface HostTreeHostContextMenuHandlers {
onConnect: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onDeleteHost: (host: Host) => void;
}
export const HostTreeHostContextMenuContent: React.FC<
HostTreeHostContextMenuHandlers & { host: Host }
> = ({
host,
onConnect,
onCopyCredentials,
onDeleteHost,
}) => {
const { t } = useI18n();
const safeHost = sanitizeHost(host);
return (
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
);
};
export interface HostTreeGroupContextMenuHandlers {
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
onUnmanageGroup?: (groupPath: string) => void;
}
export const HostTreeGroupContextMenuContent: React.FC<
HostTreeGroupContextMenuHandlers & { groupPath: string; isManaged: boolean }
> = ({
groupPath,
isManaged,
onNewGroup,
onRenameGroup,
onDeleteGroup,
onUnmanageGroup,
}) => {
const { t } = useI18n();
return (
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewGroup(groupPath)}>
<Folder className="mr-2 h-4 w-4" /> {t('vault.hosts.newGroup')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onRenameGroup(groupPath)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t('vault.groups.rename')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(groupPath)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t('vault.groups.delete')}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(groupPath)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t('vault.managedSource.unmanage')}
</ContextMenuItem>
)}
</ContextMenuContent>
);
};

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
hostTreeInlineGroupDeleteStore,
useHostTreeInlineGroupDeleteTarget,
} from '../../application/state/hostTreeInlineGroupDeleteStore';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
type HostTreeGroupDeleteDialogProps = {
managedGroupPaths?: Set<string>;
onConfirmDelete: (groupPath: string, deleteHosts: boolean) => void | Promise<void>;
};
export const HostTreeGroupDeleteDialog: React.FC<HostTreeGroupDeleteDialogProps> = ({
managedGroupPaths,
onConfirmDelete,
}) => {
const { t } = useI18n();
const targetPath = useHostTreeInlineGroupDeleteTarget();
const [deleteHosts, setDeleteHosts] = useState(false);
const isOpen = Boolean(targetPath);
const isManaged = Boolean(targetPath && managedGroupPaths?.has(targetPath));
useEffect(() => {
if (!isOpen) {
setDeleteHosts(false);
}
}, [isOpen]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) hostTreeInlineGroupDeleteStore.close();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('vault.groups.deleteDialogTitle')}</DialogTitle>
<DialogDescription>
{isManaged
? t('vault.groups.deleteDialog.managedDesc')
: t('vault.groups.deleteDialog.desc')}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{targetPath && (
<>
<p className="text-sm text-muted-foreground">
{t('vault.groups.pathLabel')}:{' '}
<span className="font-mono">{targetPath}</span>
</p>
{!isManaged && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={deleteHosts}
onChange={(event) => setDeleteHosts(event.target.checked)}
className="rounded border-border"
/>
<span>{t('vault.groups.deleteDialog.deleteHosts')}</span>
</label>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => hostTreeInlineGroupDeleteStore.close()}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
if (!targetPath) return;
void Promise.resolve(onConfirmDelete(targetPath, isManaged || deleteHosts)).finally(() => {
hostTreeInlineGroupDeleteStore.close();
setDeleteHosts(false);
});
}}
>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,69 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
type HostTreeGroupInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
};
export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameInputProps> = ({
initialName,
onCommit,
onCancel,
className,
style,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
onCancel();
};
return (
<input
ref={inputRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={commit}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
if (event.key === 'Escape') {
event.preventDefault();
cancel();
}
}}
className={cn(
'min-w-0 flex-1 truncate rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}
/>
);
};

View File

@@ -70,19 +70,20 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
className="z-[200000] max-h-80 w-max max-w-[var(--radix-select-content-available-width)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
<SelectPrimitive.Viewport className="p-1">
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
className="relative flex w-full min-w-max cursor-default select-none items-center whitespace-nowrap rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
@@ -90,7 +91,7 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2 whitespace-nowrap">
{opt.icon}
{opt.label}
</span>
@@ -112,8 +113,67 @@ export const SectionHeader: React.FC<{ title: string; className?: string }> = ({
className,
}) => <h3 className={cn("text-sm font-semibold text-foreground mb-3", className)}>{title}</h3>;
/** Section title row → content gap (shared across settings pages). */
export const settingsSectionGapClassName = "gap-2";
/** Groups a section title (optional icon/actions) with its content at a uniform gap. */
export const SettingsSection: React.FC<{
title?: string;
leading?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}> = ({ title, leading, actions, children, className }) => (
<section className={cn("flex flex-col", settingsSectionGapClassName, className)}>
{(title || leading || actions) && (
<div
className={cn(
"flex min-h-8 items-center gap-2",
actions && "justify-between gap-4",
)}
>
<div className="flex min-w-0 items-center gap-2">
{leading}
{title ? <h3 className="text-sm font-semibold text-foreground">{title}</h3> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)}
{children}
</section>
);
export const settingCardClassName = "rounded-lg border bg-card";
interface SettingCardProps {
children: React.ReactNode;
className?: string;
/** Row list with dividers; vertical spacing comes from SettingRow. */
divided?: boolean;
/** Free-form content; apply even padding on all sides. */
padded?: boolean;
}
export const SettingCard: React.FC<SettingCardProps> = ({
children,
className,
divided = false,
padded = false,
}) => (
<div
className={cn(
settingCardClassName,
padded ? "p-4" : "px-4",
divided && "space-y-0 divide-y divide-border",
className,
)}
>
{children}
</div>
);
interface SettingRowProps {
label: string;
label?: string;
description?: string;
children: React.ReactNode;
}
@@ -121,8 +181,10 @@ interface SettingRowProps {
export const SettingRow: React.FC<SettingRowProps> = ({ label, description, children }) => (
<div className="flex items-center justify-between py-3 gap-4">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{label}</div>
{description && <div className="text-xs text-muted-foreground mt-0.5">{description}</div>}
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
<div className={cn("text-xs text-muted-foreground", label && "mt-0.5")}>{description}</div>
)}
</div>
<div className="shrink-0">{children}</div>
</div>

View File

@@ -7,7 +7,7 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import { AlertTriangle, Bot, FolderOpen, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
@@ -20,9 +20,8 @@ import type {
import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
@@ -454,30 +453,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, []);
return (
<TabsContent
value="ai"
className="data-[state=inactive]:hidden h-full flex flex-col"
>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{t('ai.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('ai.description')}
</p>
</div>
{/* -- Providers Section -- */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.providers')}</h3>
</div>
<AddProviderDropdown onAdd={handleAddProvider} />
</div>
<SettingsTabContent value="ai">
<SettingsSection
title={t('ai.providers')}
actions={<AddProviderDropdown onAdd={handleAddProvider} />}
>
{providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
@@ -534,15 +514,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
))}
</div>
)}
</div>
{/* -- Codex Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="openai" size="sm" />
<h3 className="text-base font-medium">{t('ai.codex')}</h3>
</div>
</SettingsSection>
<SettingsSection
title={t('ai.codex')}
leading={<ProviderIconBadge providerId="openai" size="sm" />}
>
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isResolvingCodex}
@@ -559,15 +536,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
onOpenUrl={handleOpenCodexLoginUrl}
onLogout={() => void handleCodexLogout()}
/>
</div>
{/* -- Claude Code Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="claude" size="sm" />
<h3 className="text-base font-medium">{t('ai.claude.title')}</h3>
</div>
</SettingsSection>
<SettingsSection
title={t('ai.claude.title')}
leading={<ProviderIconBadge providerId="claude" size="sm" />}
>
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isResolvingClaude}
@@ -581,15 +555,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
envText={claudeEnvText}
onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, claudeSettingsPath, v)}
/>
</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>
</SettingsSection>
<SettingsSection
title={t('ai.copilot.title')}
leading={<ProviderIconBadge providerId="copilot" size="sm" />}
>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
@@ -597,21 +568,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
</SettingsSection>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<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.defaultAgent')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<SettingRow
label={t('ai.defaultAgent')}
description={t('ai.defaultAgent.description')}
>
<SettingsSection title={t('ai.defaultAgent')}>
<SettingCard>
<SettingRow description={t('ai.defaultAgent.description')}>
<Select
value={defaultAgentId}
options={agentOptions}
@@ -619,21 +581,13 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
className="w-64"
/>
</SettingRow>
</div>
</div>
</SettingCard>
</SettingsSection>
)}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Link 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')}
>
<SettingsSection title={t('ai.toolAccess.title')}>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>
<Select
value={toolIntegrationMode}
options={[
@@ -644,16 +598,13 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
className="w-48"
/>
</SettingRow>
</div>
</div>
</SettingCard>
</SettingsSection>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
</div>
<div className="flex items-center gap-2">
<SettingsSection
title={t('ai.userSkills.title')}
actions={(
<>
<Button
variant="outline"
size="sm"
@@ -672,10 +623,10 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<FolderOpen size={14} className="mr-2" />
{t('ai.userSkills.openFolder')}
</Button>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
</>
)}
>
<SettingCard padded className="space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('ai.userSkills.description')}
@@ -744,10 +695,9 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
{t('ai.userSkills.empty')}
</div>
) : null}
</div>
</div>
</SettingCard>
</SettingsSection>
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
@@ -764,9 +714,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
maxIterations={maxIterations}
setMaxIterations={setMaxIterations}
/>
</div>
</div>
</TabsContent>
</SettingsTabContent>
);
};

View File

@@ -32,6 +32,8 @@ export default function SettingsAppearanceTab(props: {
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
showSftpTab: boolean;
setShowSftpTab: (enabled: boolean) => void;
windowOpacity: number;
setWindowOpacity: (opacity: number) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -58,8 +60,16 @@ export default function SettingsAppearanceTab(props: {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
windowOpacity,
setWindowOpacity,
} = props;
const WINDOW_OPACITY_PRESETS = [
{ label: '100%', value: 1 },
{ label: '85%', value: 0.85 },
{ label: '70%', value: 0.7 },
] as const;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -172,6 +182,48 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.windowOpacity")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.windowOpacity")}
description={t("settings.appearance.windowOpacity.desc")}
>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
type="range"
min={50}
max={100}
step={5}
value={Math.round(windowOpacity * 100)}
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
className="w-28 accent-primary"
/>
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
{Math.round(windowOpacity * 100)}%
</span>
</div>
<div className="flex items-center gap-1.5">
{WINDOW_OPACITY_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setWindowOpacity(preset.value)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
windowOpacity === preset.value
? "bg-primary text-primary-foreground border-primary"
: "bg-muted/50 text-muted-foreground border-border hover:text-foreground",
)}
>
{preset.label}
</button>
))}
</div>
</div>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow

View File

@@ -2,17 +2,22 @@
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
*/
import { FileType, Pencil, Trash2 } from "lucide-react";
import React, { useCallback, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { SectionHeader, SettingsTabContent } from "../settings-ui";
import {
SectionHeader,
SettingCard,
SettingsTabContent,
SettingRow,
Select,
Toggle,
} from "../settings-ui";
const getOpenerLabel = (
openerType: FileOpenerType,
@@ -30,12 +35,18 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
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 { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
const associations = getAllAssociations();
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const defaultOpenerValue = useMemo(() => {
if (!defaultOpener) return 'ask';
if (defaultOpener.openerType === 'builtin-editor') return 'builtin-editor';
return 'system-app';
}, [defaultOpener]);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
@@ -58,6 +69,18 @@ export default function SettingsFileAssociationsTab() {
}
}, [setDefaultOpener]);
const handleDefaultOpenerChange = useCallback((value: string) => {
if (value === 'ask') {
removeDefaultOpener();
return;
}
if (value === 'builtin-editor') {
setDefaultOpener('builtin-editor');
return;
}
void handleSelectDefaultSystemApp();
}, [handleSelectDefaultSystemApp, removeDefaultOpener, setDefaultOpener]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
@@ -78,314 +101,90 @@ export default function SettingsFileAssociationsTab() {
return (
<SettingsTabContent value="file-associations">
<div className="space-y-8">
{/* Double-click behavior section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDoubleClickBehavior('open')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'open'
? "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",
sftpDoubleClickBehavior === 'open'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'open' && (
<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.doubleClickBehavior.open')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.openDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDoubleClickBehavior('transfer')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'transfer'
? "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",
sftpDoubleClickBehavior === 'transfer'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'transfer' && (
<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.doubleClickBehavior.transfer')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.transferDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
<SettingCard>
<SettingRow description={t('settings.sftp.doubleClickBehavior.desc')}>
<Select
value={sftpDoubleClickBehavior}
options={[
{ value: 'open', label: t('settings.sftp.doubleClickBehavior.open') },
{ value: 'transfer', label: t('settings.sftp.doubleClickBehavior.transfer') },
]}
onChange={(value) => setSftpDoubleClickBehavior(value as 'open' | 'transfer')}
className="w-48"
/>
</SettingRow>
</SettingCard>
{/* 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>
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
<SettingCard>
<SettingRow description={t('settings.sftp.defaultViewMode.desc')}>
<Select
value={sftpDefaultViewMode}
options={[
{ value: 'list', label: t('settings.sftp.defaultViewMode.list') },
{ value: 'tree', label: t('settings.sftp.defaultViewMode.tree') },
]}
onChange={(value) => setSftpDefaultViewMode(value as 'list' | 'tree')}
className="w-48"
/>
</SettingRow>
</SettingCard>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.desc')}
</p>
<button
onClick={() => setSftpAutoSync(!sftpAutoSync)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoSync
? "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 border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoSync
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoSync && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoSync.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoSync.enableDesc')}
</p>
</div>
</div>
</button>
</div>
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.showHiddenFiles.enable')}
description={t('settings.sftp.showHiddenFiles.enableDesc')}
>
<Toggle checked={sftpShowHiddenFiles} onChange={setSftpShowHiddenFiles} />
</SettingRow>
</SettingCard>
{/* Show hidden files section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.desc')}
</p>
<button
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpShowHiddenFiles
? "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 border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpShowHiddenFiles
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpShowHiddenFiles && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.showHiddenFiles.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.enableDesc')}
</p>
</div>
</div>
</button>
</div>
<SectionHeader title={t('settings.sftp.autoSync')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.autoSync.enable')}
description={t('settings.sftp.autoSync.enableDesc')}
>
<Toggle checked={sftpAutoSync} onChange={setSftpAutoSync} />
</SettingRow>
</SettingCard>
{/* Compressed folder upload section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.desc')}
</p>
<button
onClick={() => setSftpUseCompressedUpload(!sftpUseCompressedUpload)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpUseCompressedUpload
? "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 border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpUseCompressedUpload
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpUseCompressedUpload && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.compressedUpload.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.enableDesc')}
</p>
</div>
</div>
</button>
</div>
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.compressedUpload.enable')}
description={t('settings.sftp.compressedUpload.enableDesc')}
>
<Toggle checked={sftpUseCompressedUpload} onChange={setSftpUseCompressedUpload} />
</SettingRow>
</SettingCard>
{/* Auto-open sidebar section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.desc')}
</p>
<button
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoOpenSidebar
? "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 border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoOpenSidebar
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoOpenSidebar && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoOpenSidebar.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.enableDesc')}
</p>
</div>
</div>
</button>
</div>
<SectionHeader title={t('settings.sftp.followTerminalCwd')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.followTerminalCwd.enable')}
description={t('settings.sftp.followTerminalCwd.enableDesc')}
>
<Toggle checked={sftpFollowTerminalCwd} onChange={setSftpFollowTerminalCwd} />
</SettingRow>
</SettingCard>
{/* 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">
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<SettingCard>
<SettingRow
label={t('settings.sftp.autoOpenSidebar.enable')}
description={t('settings.sftp.autoOpenSidebar.enableDesc')}
>
<Toggle checked={sftpAutoOpenSidebar} onChange={setSftpAutoOpenSidebar} />
</SettingRow>
</SettingCard>
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
<SettingCard>
<SettingRow description={t('settings.sftp.transferConcurrency.desc')}>
<div className="flex items-center gap-2">
<input
type="range"
min={1}
@@ -393,188 +192,133 @@ export default function SettingsFileAssociationsTab() {
step={1}
value={sftpTransferConcurrency}
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
className="flex-1 accent-primary"
className="w-40 accent-primary"
/>
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
<span className="text-sm text-muted-foreground w-6 text-center tabular-nums">
{sftpTransferConcurrency}
</span>
</div>
</div>
</SettingRow>
</SettingCard>
{/* 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
<SectionHeader title={t('settings.sftp.defaultOpener')} />
<SettingCard>
<SettingRow description={t('settings.sftp.defaultOpener.desc')}>
<div className="flex flex-col items-end gap-2">
<Select
value={defaultOpenerValue}
options={[
{ value: 'ask', label: t('settings.sftp.defaultOpener.ask') },
{ value: 'builtin-editor', label: t('sftp.opener.builtInEditor') },
{
value: 'system-app',
label:
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>
: t('settings.sftp.defaultOpener.systemApp'),
},
]}
onChange={handleDefaultOpenerChange}
className="w-56"
disabled={isSelectingDefaultApp}
/>
{defaultOpener?.openerType === 'system-app' && (
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectDefaultSystemApp()}
disabled={isSelectingDefaultApp}
>
{t('settings.sftp.defaultOpener.systemApp')}
</Button>
)}
</div>
</div>
</SettingRow>
</SettingCard>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftpFileAssociations.desc')}
</p>
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
<p className="text-xs text-muted-foreground -mt-3 mb-1">
{t('settings.sftpFileAssociations.desc')}
</p>
{associations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
{associations.length === 0 ? (
<SettingCard className="py-12">
<div className="flex flex-col items-center justify-center text-muted-foreground">
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
</div>
) : (
<div className="border border-border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.extension')}
</th>
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.application')}
</th>
<th className="text-right px-4 py-2 font-medium w-28">
{/* Actions */}
</th>
</SettingCard>
) : (
<div className="rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.extension')}
</th>
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.application')}
</th>
<th className="text-right px-4 py-2 font-medium w-28">
{/* Actions */}
</th>
</tr>
</thead>
<tbody>
{associations.map(({ extension, openerType, systemApp }) => (
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-3">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
</thead>
<tbody>
{associations.map(({ extension, openerType, systemApp }) => (
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-3">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
))}
</tbody>
</table>
</div>
</div>
)}
</SettingsTabContent>
);
}

View File

@@ -1,17 +1,16 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { Toggle, Select, SettingRow, SectionHeader, SettingCard, SettingsTabContent } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface CrashLogFile {
@@ -392,27 +391,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
];
return (
<TabsContent
value="system"
className="data-[state=inactive]:hidden h-full flex flex-col"
>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{t("settings.system.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t("settings.system.description")}
</p>
</div>
{/* Software Update Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('settings.update.title')}</h3>
</div>
<div className="rounded-lg border border-border/60 p-4 space-y-3">
<SettingsTabContent value="system">
<SectionHeader title={t('settings.update.title')} />
<SettingCard className="space-y-3 py-4">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
@@ -525,16 +506,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Button>
)}
</div>
</div>
<SettingRow
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
<SettingRow
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
</SettingCard>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
@@ -545,16 +526,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
)}
{t('settings.update.hint')}
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<SectionHeader title={t("settings.system.credentials.title")} />
<SettingCard className="space-y-3 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
@@ -597,17 +571,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</div>
</div>
</SettingCard>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<SectionHeader title={t("settings.system.crashLogs.title")} />
<SettingCard className="space-y-3 py-4">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
@@ -744,21 +711,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.tempDirectory")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<SectionHeader title={t("settings.system.tempDirectory")} />
<SettingCard className="space-y-3 py-4">
{/* Path */}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
@@ -832,21 +792,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
})}
</p>
)}
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t("settings.system.tempDirectoryHint")}
</p>
</div>
{/* Session Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
<SectionHeader title={t("settings.sessionLogs.title")} />
<SettingCard className="space-y-4 py-4">
{/* Enable Toggle */}
<SettingRow
label={t("settings.sessionLogs.enableAutoSave")}
@@ -922,21 +875,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
disabled={!sessionLogsEnabled}
/>
</SettingRow>
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.hint")}
</p>
</div>
{/* SSH Debug Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sshDebugLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
<SectionHeader title={t("settings.sshDebugLogs.title")} />
<SettingCard className="min-w-0 max-w-full overflow-hidden space-y-4 py-4">
<SettingRow
label={t("settings.sshDebugLogs.enable")}
description={t("settings.sshDebugLogs.enableDesc")}
@@ -949,9 +895,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<div className="space-y-2">
<span className="text-sm font-medium">{t("settings.sshDebugLogs.location")}</span>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
<div className="grid w-full min-w-0 max-w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
<div className="min-w-0 overflow-hidden">
<div
className="w-full min-w-0 overflow-hidden truncate rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
title={isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
>
{isLoadingSshDebugLogInfo ? "..." : (sshDebugLogInfo?.path || "-")}
</div>
</div>
@@ -989,21 +938,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</span>
</div>
</div>
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t("settings.sshDebugLogs.hint")}
</p>
</div>
{/* Global Toggle Window Section (Quake Mode) */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Keyboard size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
<SectionHeader title={t("settings.globalHotkey.title")} />
<SettingCard className="space-y-4 py-4">
{/* Enable/Disable Global Hotkey */}
<SettingRow
label={t('settings.globalHotkey.enabled')}
@@ -1068,15 +1010,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
onChange={setCloseToTray}
/>
</SettingRow>
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t("settings.globalHotkey.hint")}
</p>
</div>
</div>
</div>
</TabsContent>
</SettingsTabContent>
);
};

View File

@@ -885,7 +885,7 @@ export default function SettingsTerminalTab(props: {
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
<div className="space-y-1">
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.workspaceFocus.style")}
description={t("settings.terminal.workspaceFocus.style.desc")}
@@ -897,6 +897,7 @@ export default function SettingsTerminalTab(props: {
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
]}
className="w-40"
/>
</SettingRow>
</div>

View File

@@ -4,7 +4,6 @@ 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";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
@@ -65,17 +64,11 @@ export const ClaudeCodeCard: React.FC<{
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="rounded-lg border bg-card 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="claude" size="sm" />
<span className="text-sm font-medium">{t('ai.claude.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.claude.description')}
</p>
</div>
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.claude.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>

View File

@@ -4,7 +4,6 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo, CodexIntegrationStatus, CodexLoginSession } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CodexConnectionCard: React.FC<{
pathInfo: AgentPathInfo | null;
@@ -87,17 +86,11 @@ export const CodexConnectionCard: React.FC<{
: "";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="rounded-lg border bg-card 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="openai" size="sm" />
<span className="text-sm font-medium">{t('ai.codex.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.codex.description')}
</p>
</div>
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codex.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{status}
</div>

View File

@@ -4,7 +4,6 @@ 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;
@@ -35,17 +34,11 @@ export const CopilotCliCard: React.FC<{
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="rounded-lg border bg-card 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>
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.copilot.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>

View File

@@ -25,7 +25,7 @@ export const ProviderCard: React.FC<{
<div
className={cn(
"rounded-lg border p-4 transition-colors",
isActive ? "border-primary/50 bg-primary/5" : "border-border/60 bg-muted/20",
isActive ? "border-primary/50 bg-primary/5" : "border-border bg-card",
)}
>
<div className="flex items-center gap-3">

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useState } from "react";
import { Plus, Shield, X } from "lucide-react";
import { Plus, X } from "lucide-react";
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
import { DEFAULT_COMMAND_BLOCKLIST } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Select, SettingRow } from "../../settings-ui";
import { Select, SettingCard, SettingRow, SettingsSection } from "../../settings-ui";
export const SafetySettings: React.FC<{
globalPermissionMode: AIPermissionMode;
@@ -68,13 +68,9 @@ export const SafetySettings: React.FC<{
];
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.safety.title')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
<SettingsSection title={t('ai.safety.title')}>
<div className="flex flex-col gap-4">
<SettingCard divided>
<SettingRow
label={t('ai.safety.permissionMode')}
description={t('ai.safety.permissionMode.description')}
@@ -123,10 +119,10 @@ export const SafetySettings: React.FC<{
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
</SettingCard>
{/* Command Blocklist */}
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<SettingCard padded className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
@@ -194,11 +190,12 @@ export const SafetySettings: React.FC<{
<Plus size={14} className="mr-1" />
{t('ai.safety.blocklist.add')}
</Button>
</div>
</SettingCard>
<p className="text-xs text-muted-foreground">
{t('ai.safety.note')}
</p>
</div>
<p className="text-xs text-muted-foreground">
{t('ai.safety.note')}
</p>
</div>
</SettingsSection>
);
};

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Globe, Eye, EyeOff } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingRow } from "../../settings-ui";
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
@@ -114,27 +114,16 @@ export const WebSearchSettings: React.FC<{
}, [apiKeyInput, updateConfig]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
{/* Enable/Disable */}
<SettingsSection title={t("ai.webSearch.title")}>
<SettingCard divided>
<SettingRow
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</label>
<Toggle
checked={config.enabled}
onChange={(enabled) => updateConfig({ enabled })}
/>
</SettingRow>
{/* Provider */}
@@ -214,7 +203,7 @@ export const WebSearchSettings: React.FC<{
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
</div>
</SettingCard>
</SettingsSection>
);
};

View File

@@ -66,8 +66,11 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
return (
<div
data-sftp-row="true"
data-section="terminal-sftp-list-row"
data-entry-name={entry.name}
data-selected={isSelected ? "true" : "false"}
data-entry-type={isNavDir ? "directory" : entry.type}
data-drag-over={isDragOver ? "true" : "false"}
draggable={!isParentDir}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}

View File

@@ -504,6 +504,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
{/* File list header */}
<div
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
data-section="terminal-sftp-list-header"
style={{
display: "grid",
gridTemplateColumns: buildSftpColumnTemplate(columnWidths),
@@ -572,6 +573,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
<ContextMenuTrigger asChild>
<div
ref={fileListRef}
data-section="terminal-sftp-list"
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, 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";
@@ -53,6 +53,8 @@ interface SftpPaneToolbarProps {
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
viewMode: 'list' | 'tree';
onSetViewMode: (mode: 'list' | 'tree') => void;
onListDrives?: () => Promise<string[]>;
@@ -104,6 +106,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
followTerminalCwd,
onToggleFollowTerminalCwd,
viewMode,
onSetViewMode,
onListDrives,
@@ -174,6 +178,25 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
{onToggleFollowTerminalCwd && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", followTerminalCwd && "bg-secondary text-primary")}
aria-pressed={!!followTerminalCwd}
aria-label={t("sftp.followTerminalCwd")}
onClick={onToggleFollowTerminalCwd}
>
<FolderSync size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>
{followTerminalCwd ? t("sftp.followTerminalCwd.disable") : t("sftp.followTerminalCwd.enable")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -424,10 +447,14 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{/* Toolbar - always visible when connected */}
<div ref={outerRef} className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<div
ref={outerRef}
className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20"
data-section="terminal-sftp-toolbar"
>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<div className="relative flex-1" data-section="terminal-sftp-path">
<Input
ref={pathInputRef}
value={editingPathValue}
@@ -481,6 +508,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
<TooltipTrigger asChild>
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
data-section="terminal-sftp-path"
onDoubleClick={handlePathDoubleClick}
>
<SftpBreadcrumb
@@ -630,7 +658,10 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div
className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10"
data-section="terminal-sftp-filter-bar"
>
<div className="relative flex-1">
<Search
size={12}

View File

@@ -51,6 +51,12 @@ export const TreeNode = React.memo<TreeNodeProps>(({
return (
<div
data-section="terminal-sftp-tree-row"
data-entry-name={entry.name}
data-entry-type={isDir ? 'directory' : entry.type}
data-selected={isSelected ? 'true' : 'false'}
data-expanded={isDir ? (isExpanded ? 'true' : 'false') : undefined}
data-drag-over={isDragOver ? 'true' : 'false'}
className={cn(
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
isSelected

View File

@@ -77,6 +77,8 @@ interface SftpPaneViewProps {
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
forceActive?: boolean;
}
@@ -91,6 +93,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
followTerminalCwd,
onToggleFollowTerminalCwd,
forceActive,
}) => {
const activeTabId = useActiveTabId(side);
@@ -474,6 +478,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
return (
<div
ref={paneContainerRef}
data-section="terminal-sftp-pane"
data-sftp-pane-side={side}
data-sftp-view-mode={viewMode}
className={cn(
"absolute inset-0 flex flex-col transition-colors",
isDragOverPane && "bg-primary/5",
@@ -523,13 +530,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
followTerminalCwd={followTerminalCwd}
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
onListDrives={callbacks.onListDrives}
/>
{treeEverMounted && (
<div className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<div
className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
data-section="terminal-sftp-tree"
>
<SftpPaneTreeView
pane={pane}
side={side}
@@ -566,56 +578,58 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
/>
</div>
)}
<div className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
isPaneFocused={isPaneFocused}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
fileListRef={fileListRef}
handleFileListScroll={handleFileListScroll}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={handleRefresh}
onNavigateTo={callbacks.onNavigateTo}
onClearSelection={callbacks.onClearSelection}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
dragOverEntry={dragOverEntry}
handleRowSelect={handleRowSelect}
handleRowOpen={handleRowOpen}
handleFileDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
handleEntryDragOver={handleEntryDragOver}
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
isLocal={!!pane.connection?.isLocal}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
<div
className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}
>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
isPaneFocused={isPaneFocused}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
fileListRef={fileListRef}
handleFileListScroll={handleFileListScroll}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={handleRefresh}
onNavigateTo={callbacks.onNavigateTo}
onClearSelection={callbacks.onClearSelection}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
dragOverEntry={dragOverEntry}
handleRowSelect={handleRowSelect}
handleRowOpen={handleRowOpen}
handleFileDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
handleEntryDragOver={handleEntryDragOver}
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onEditPermissions={callbacks.onEditPermissions}
onUploadExternalFileList={handleUploadExternalFileList}
onUploadExternalFolder={handleUploadExternalFolder}
isLocal={!!pane.connection?.isLocal}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
</div>
<SftpPaneDialogs
@@ -685,6 +699,10 @@ const sftpPaneViewAreEqual = (
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
if (prev.followTerminalCwd !== next.followTerminalCwd) return false;
if (prev.onToggleFollowTerminalCwd !== next.onToggleFollowTerminalCwd) return false;
if (prev.onGoToTerminalCwd !== next.onGoToTerminalCwd) return false;
if (prev.onToggleShowHiddenFiles !== next.onToggleShowHiddenFiles) return false;
return true;
};

View File

@@ -165,7 +165,9 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
const childProgressBar = (
<div className="relative h-full overflow-hidden border border-border/60 bg-secondary/70">
<div
className="relative h-full overflow-hidden border border-border/60 bg-secondary/70"
>
<div
className={cn(
"h-full relative overflow-hidden",
@@ -287,6 +289,9 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const content = isChild ? (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
style={{
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
}}
@@ -366,7 +371,12 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
) : null;
return (
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm">
<div
className="border-t border-border/40 bg-background/60 px-3 py-2.5 supports-[backdrop-filter]:backdrop-blur-sm"
data-section="terminal-sftp-transfer-row"
data-transfer-status={task.status}
data-transfer-direction={task.direction}
>
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}

View File

@@ -347,6 +347,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
return (
<div
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
data-section="terminal-sftp-transfer-queue"
style={{ height: clampPanelHeight(panelHeight) }}
>
<Tooltip>
@@ -361,7 +362,10 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
<TooltipContent>{t("sftp.transfers.dragToResize")}</TooltipContent>
</Tooltip>
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
<div
className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground"
data-section="terminal-sftp-transfer-queue-header"
>
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
@@ -388,6 +392,7 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
<div
ref={scrollContainerRef}
className="overflow-auto"
data-section="terminal-sftp-transfer-list"
style={{ height: `calc(100% - ${HEADER_HEIGHT}px)` }}
onScroll={handleScroll}
>

View File

@@ -75,7 +75,7 @@ export function createDropEntriesFromClipboardFiles(files: ClipboardLocalFile[])
}
export function getSupportedClipboardUploadFiles(files: ClipboardLocalFile[]): ClipboardLocalFile[] {
return files.filter((file) => !file.isDirectory);
return files;
}
export function shouldLetNativePasteEventHandleSftpPaste(

View File

@@ -19,6 +19,7 @@ import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
import type { SftpFileEntry } from "../../../types";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import { toast } from "../../ui/toast";
import {
createDropEntriesFromClipboardFiles,
@@ -214,10 +215,6 @@ export const useSftpKeyboardShortcuts = ({
) => {
const sftp = sftpRef.current;
const uploadFiles = getSupportedClipboardUploadFiles(files);
const skippedDirectoryCount = files.length - uploadFiles.length;
if (skippedDirectoryCount > 0) {
toast.info("Folder paste is not supported yet. Only files will be uploaded.", "SFTP");
}
if (uploadFiles.length === 0) return;
const entries = createDropEntriesFromClipboardFiles(uploadFiles);
@@ -229,7 +226,39 @@ export const useSftpKeyboardShortcuts = ({
files: uploadFiles,
onConfirm: async () => {
try {
const results = await sftp.uploadExternalEntries(focusedSide, entries, { targetPath });
const results: Awaited<ReturnType<SftpStateApi["uploadExternalEntries"]>> = [];
const fileEntries: DropEntry[] = [];
for (const file of uploadFiles) {
if (file.isDirectory) {
try {
const folderResults = await sftp.uploadExternalFolderPath(focusedSide, file.path, targetPath);
results.push(...folderResults);
} catch (error) {
results.push({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
} else {
fileEntries.push(
entries.find((entry) => entry.localPath === file.path) ?? {
file: null,
localPath: file.path,
relativePath: file.name,
isDirectory: false,
size: file.size,
},
);
}
}
if (fileEntries.length > 0) {
const fileResults = await sftp.uploadExternalEntries(focusedSide, fileEntries, { targetPath });
results.push(...fileResults);
}
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
@@ -238,28 +267,36 @@ export const useSftpKeyboardShortcuts = ({
});
}, [dialogActionScopeId, showUploadResults, sftpRef]);
const triggerFileListClipboardUpload = useCallback((
files: File[],
const triggerDropEntriesClipboardUpload = useCallback((
entries: DropEntry[],
focusedSide: "left" | "right",
targetPath: string,
) => {
const sftp = sftpRef.current;
const bridge = netcattyBridge.get();
const dialogFiles: ClipboardLocalFile[] = files.map((file) => ({
path: bridge?.getPathForFile?.(file) || file.name,
name: file.name,
isDirectory: false,
size: file.size,
}));
if (entries.length === 0) return;
const rootNames = new Set<string>();
const previewFiles: ClipboardLocalFile[] = [];
for (const entry of entries) {
const rootName = entry.relativePath.split("/")[0];
if (rootNames.has(rootName)) continue;
rootNames.add(rootName);
previewFiles.push({
path: entry.localPath ?? entry.relativePath,
name: rootName,
isDirectory: entry.isDirectory,
size: entry.size,
});
}
sftpClipboardUploadStore.trigger({
scopeId: dialogActionScopeId,
side: focusedSide,
targetPath,
files: dialogFiles,
files: previewFiles,
onConfirm: async () => {
try {
const results = await sftp.uploadExternalFileList(focusedSide, files, targetPath);
const results = await sftp.uploadExternalEntries(focusedSide, entries, { targetPath });
showUploadResults(results);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed.", "SFTP");
@@ -369,51 +406,74 @@ export const useSftpKeyboardShortcuts = ({
const target = e.target as HTMLElement;
if (isEditableShortcutTarget(target) || hasOpenDialog()) return;
const hasInternalClipboardFiles = sftpClipboardStore.hasFiles();
const hasInternalClipboardFiles = sftpClipboardStore.hasFiles();
const { focusedSide, pane } = getFocusedPane();
if (!pane?.connection) return;
const targetPath = getClipboardUploadTarget(pane);
const pendingClipboardWrite = pendingSftpSystemClipboardWrite;
if (pendingClipboardWrite && hasInternalClipboardFiles) {
e.preventDefault();
e.stopPropagation();
void pendingClipboardWrite.finally(() => {
void pasteInternalSftpClipboard(focusedSide, pane);
});
return;
}
const pastedFiles = Array.from(e.clipboardData?.files ?? []).filter((file) => file.name);
if (pastedFiles.length > 0) {
e.preventDefault();
e.stopPropagation();
triggerFileListClipboardUpload(pastedFiles, focusedSide, targetPath);
return;
}
const bridge = netcattyBridge.get();
const clipboardFilesPromise = bridge?.readClipboardFiles?.();
if (!clipboardFilesPromise) {
if (!hasInternalClipboardFiles) return;
e.preventDefault();
e.stopPropagation();
void pasteInternalSftpClipboard(focusedSide, pane);
const dataTransfer = e.clipboardData;
const hasClipboardItems = (dataTransfer?.items?.length ?? 0) > 0;
// webkitGetAsEntry must be invoked synchronously during the paste event.
const dropEntriesPromise = dataTransfer?.items?.length
? extractDropEntries(dataTransfer)
: null;
const pastedFileSnapshot = dataTransfer?.files?.length
? Array.from(dataTransfer.files).filter((file) => file.name)
: [];
if (!hasInternalClipboardFiles && !hasClipboardItems && !bridge?.readClipboardFiles) {
return;
}
const runPaste = async () => {
if (pendingClipboardWrite && hasInternalClipboardFiles) {
await pendingClipboardWrite;
await pasteInternalSftpClipboard(focusedSide, pane);
return;
}
if (bridge?.readClipboardFiles) {
const clipboardFiles = await bridge.readClipboardFiles();
if (clipboardFiles.length > 0) {
triggerPathBackedClipboardUpload(clipboardFiles, focusedSide, targetPath);
return;
}
}
if (dropEntriesPromise) {
const entries = await dropEntriesPromise;
if (entries.length > 0) {
triggerDropEntriesClipboardUpload(entries, focusedSide, targetPath);
return;
}
}
if (pastedFileSnapshot.length > 0) {
const pathBackedFiles: ClipboardLocalFile[] = pastedFileSnapshot
.map((file) => ({
path: bridge?.getPathForFile?.(file) || file.name,
name: file.name,
isDirectory: false,
size: file.size,
}))
.filter((file) => file.path.includes("/") || file.path.includes("\\"));
if (pathBackedFiles.length > 0) {
triggerPathBackedClipboardUpload(pathBackedFiles, focusedSide, targetPath);
return;
}
}
if (hasInternalClipboardFiles) {
await pasteInternalSftpClipboard(focusedSide, pane);
}
};
e.preventDefault();
e.stopPropagation();
void clipboardFilesPromise.then((files) => {
if (files.length === 0) {
if (hasInternalClipboardFiles) {
void pasteInternalSftpClipboard(focusedSide, pane);
}
return;
}
triggerPathBackedClipboardUpload(files, focusedSide, targetPath);
});
void runPaste();
},
[
getClipboardUploadTarget,
@@ -422,7 +482,7 @@ export const useSftpKeyboardShortcuts = ({
isActive,
keyBindings,
pasteInternalSftpClipboard,
triggerFileListClipboardUpload,
triggerDropEntriesClipboardUpload,
triggerPathBackedClipboardUpload,
],
);
@@ -832,13 +892,15 @@ export const useSftpKeyboardShortcuts = ({
);
useEffect(() => {
if (!isActive) return;
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
}, [handleKeyDown, isActive]);
useEffect(() => {
if (!isActive) return;
window.addEventListener("paste", handlePaste, true);
return () => window.removeEventListener("paste", handlePaste, true);
}, [handlePaste]);
}, [handlePaste, isActive]);
};

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldFollowTerminalCwdNavigate } from "./sftpFollowTerminalCwd";
const base = {
followEnabled: true,
isVisible: true,
terminalCwd: "/home/user/project",
currentPath: "/home/user",
hasActiveWork: false,
isConnected: true,
};
test("shouldFollowTerminalCwdNavigate returns true when follow is on and paths differ", () => {
assert.equal(shouldFollowTerminalCwdNavigate(base), true);
});
test("shouldFollowTerminalCwdNavigate returns false when paths already match", () => {
assert.equal(
shouldFollowTerminalCwdNavigate({ ...base, currentPath: "/home/user/project" }),
false,
);
});
test("shouldFollowTerminalCwdNavigate returns false when follow is disabled", () => {
assert.equal(shouldFollowTerminalCwdNavigate({ ...base, followEnabled: false }), false);
});
test("shouldFollowTerminalCwdNavigate returns false while interactive work is active", () => {
assert.equal(shouldFollowTerminalCwdNavigate({ ...base, hasActiveWork: true }), false);
});
test("shouldFollowTerminalCwdNavigate returns false without a known terminal cwd", () => {
assert.equal(shouldFollowTerminalCwdNavigate({ ...base, terminalCwd: null }), false);
});

View File

@@ -0,0 +1,24 @@
export type SftpFollowTerminalCwdContext = {
followEnabled: boolean;
isVisible: boolean;
terminalCwd?: string | null;
currentPath?: string | null;
hasActiveWork: boolean;
isConnected: boolean;
};
/** Whether SFTP should auto-navigate to match the linked terminal cwd. */
export const shouldFollowTerminalCwdNavigate = ({
followEnabled,
isVisible,
terminalCwd,
currentPath,
hasActiveWork,
isConnected,
}: SftpFollowTerminalCwdContext): boolean => {
if (!followEnabled || !isVisible || !isConnected) return false;
if (hasActiveWork) return false;
if (!terminalCwd || terminalCwd.trim().length === 0) return false;
if (!currentPath || currentPath === terminalCwd) return false;
return true;
};

View File

@@ -7,6 +7,7 @@ import {
type AutocompleteSettings,
} from "./autocomplete";
import type { Snippet } from "../../domain/models";
import { usePaneVisible } from "./paneVisibilityStore";
type PopupProps = ComponentProps<typeof AutocompletePopup>;
@@ -24,8 +25,6 @@ interface TerminalAutocompleteProps {
onAcceptText: (text: string) => void;
snippets?: Snippet[];
onAcceptSnippet?: (snippet: Snippet) => void;
/** Whether this terminal tab is the visible one. */
visible: boolean;
themeColors: PopupProps["themeColors"];
containerRef: PopupProps["containerRef"];
searchBarOffset: number;
@@ -61,7 +60,6 @@ export function TerminalAutocomplete({
onAcceptText,
snippets,
onAcceptSnippet,
visible,
themeColors,
containerRef,
searchBarOffset,
@@ -72,6 +70,9 @@ export function TerminalAutocomplete({
sudoHintRef,
sudoHintText,
}: TerminalAutocompleteProps) {
// Self-subscribe to this pane's visibility so toggling it doesn't have to
// flow through (and re-render) the TerminalView ctx.
const visible = usePaneVisible(sessionId);
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,

View File

@@ -155,7 +155,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8 rounded-md shrink-0" />
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} size="md" className="shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>

View File

@@ -160,7 +160,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{children}
</ContextMenuTrigger>
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
<ContextMenuContent className="w-56">
<ContextMenuContent className="w-max">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}

View File

@@ -0,0 +1,360 @@
import React from 'react';
import { ArrowDownToLine, ArrowUpFromLine, Cpu, HardDrive, MemoryStick } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { formatNetSpeed } from './terminalHelpers';
import { useServerStats } from './hooks/useServerStats';
import { usePaneVisible } from './paneVisibilityStore';
interface TerminalServerStatsProps {
sessionId: string;
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
/**
* Self-contained server-stats (CPU / Memory / Disk / Network) indicator.
*
* Owns the `useServerStats` polling itself so the periodic (~5s) stats refresh
* only re-renders this small widget — previously the hook lived at the top of
* <Terminal> and `serverStats` was threaded through the giant TerminalView ctx,
* so every refresh re-rendered the whole terminal subtree (~45ms each, even
* while idle).
*/
export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}) => {
const { t } = useI18n();
const isVisible = usePaneVisible(sessionId);
const { stats: serverStats } = useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
});
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
return (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
: serverStats.diskPercent !== null
? `${serverStats.diskPercent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{disk.used}/{disk.total}G ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{serverStats.netInterfaces.length > 0 && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
);
};

View File

@@ -1,10 +1,36 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import React, { memo } from 'react';
import { TerminalServerStats } from './TerminalServerStats';
type TerminalViewContext = Record<string, any>;
export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
/**
* Shallow-compare every ctx value. <Terminal> rebuilds the ctx object on every
* render, but many re-renders (layout/fit/visibility-of-other-panes, suppress
* toggles) don't actually change any value passed to the view — notably
* `paneLayoutKey`/`isResizing` are consumed by Terminal's hooks and are NOT in
* this ctx. Without this memo, every Terminal re-render re-rendered the whole
* (expensive) TerminalView. This only skips when EVERY value is referentially
* equal, so it can never render stale UI.
*/
function terminalViewCtxEqual(
prev: { ctx: TerminalViewContext },
next: { ctx: TerminalViewContext },
): boolean {
const a = prev.ctx;
const b = next.ctx;
if (a === b) return true;
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
if (a[key] !== b[key]) return false;
}
return true;
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Button, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
return (
<TerminalContextMenu
hasSelection={hasSelection}
@@ -102,318 +128,14 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
</Tooltip>
)}
</div>
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
: serverStats.diskPercent !== null
? `${serverStats.diskPercent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{disk.used}/{disk.total}G ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{serverStats.netInterfaces.length > 0 && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)}
<TerminalServerStats
sessionId={sessionId}
enabled={terminalSettings?.showServerStats ?? true}
refreshInterval={terminalSettings?.serverStatsRefreshInterval ?? 5}
isSupportedOs={isSupportedOs}
isConnected={status === 'connected'}
isVisible={isVisible}
/>
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
@@ -539,7 +261,6 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
snippets={snippets}
onAcceptSnippet={(snippet) => void executeSnippet(snippet)}
visible={isVisible}
themeColors={effectiveTheme.colors}
containerRef={containerRef}
searchBarOffset={isSearchOpen ? 64 : 30}
@@ -669,3 +390,6 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
</TerminalContextMenu>
);
}
export const TerminalView = memo(TerminalViewInner, terminalViewCtxEqual);
TerminalView.displayName = 'TerminalView';

View File

@@ -303,6 +303,10 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
['--terminal-panel-active' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 16%, var(--terminal-panel-bg) 84%)',
} as React.CSSProperties;
if (!isVisible) {
return null;
}
return (
<>
<div

Some files were not shown because too many files have changed in this diff Show More