Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 | ||
|
|
15ec02dcae | ||
|
|
e75c654a1a | ||
|
|
29b1eca1fd | ||
|
|
e2d036e710 | ||
|
|
094f0abe4a | ||
|
|
8ab2003dae | ||
|
|
b1b0c5648c | ||
|
|
36e5779d94 | ||
|
|
53aef452cc | ||
|
|
3ef5a64b94 | ||
|
|
c28db932a4 | ||
|
|
f2c2501fa5 | ||
|
|
b1f930a995 | ||
|
|
de60b616cd | ||
|
|
6e6a0240a7 | ||
|
|
2e2360a9fc | ||
|
|
8011f4e2e8 | ||
|
|
970037682c | ||
|
|
42b58efc5c | ||
|
|
b20163d762 | ||
|
|
e0a56cbb14 | ||
|
|
8dae851ea3 |
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in Netcatty
|
||||
title: "[Bug] "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
|
||||
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Netcatty version
|
||||
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
|
||||
placeholder: "e.g. 1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: How did you install Netcatty?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb)
|
||||
- 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & general help
|
||||
url: https://github.com/binaricat/Netcatty/discussions
|
||||
about: Not sure if it is a bug? Ask in Discussions first.
|
||||
- name: Latest release
|
||||
url: https://github.com/binaricat/Netcatty/releases/latest
|
||||
about: Check your Netcatty version before reporting.
|
||||
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
72
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Feature Request
|
||||
description: Suggest an improvement or new capability
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Describe the problem you are trying to solve and the change you want.
|
||||
Vague requests like "make it better" may be closed.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / pain point
|
||||
description: What is hard, missing, or frustrating today?
|
||||
placeholder: When I manage 50+ hosts, I cannot ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: What would you like Netcatty to do?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other tools, workarounds, or designs you thought about.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Related area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH / terminal
|
||||
- SFTP
|
||||
- Host vault / keychain
|
||||
- Port forwarding
|
||||
- Snippets
|
||||
- AI assistant
|
||||
- Settings / sync
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my daily workflow
|
||||
- Blocking / critical for my use case
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and discussions for similar requests
|
||||
required: true
|
||||
139
.github/workflows/issue-format.yml
vendored
Normal file
139
.github/workflows/issue-format.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
name: issue-format
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
|
||||
if: >-
|
||||
github.event.issue.user.type != 'Bot' &&
|
||||
!contains(github.event.issue.labels.*.name, 'format-exempt')
|
||||
steps:
|
||||
- name: Validate title and body
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title.trim();
|
||||
const body = (issue.body || '').trim();
|
||||
const errors = [];
|
||||
|
||||
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
|
||||
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
|
||||
if (!modernTitle && !legacyAppTitle) {
|
||||
errors.push(
|
||||
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
|
||||
);
|
||||
}
|
||||
|
||||
if (body.length < 120) {
|
||||
errors.push(
|
||||
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
|
||||
);
|
||||
}
|
||||
|
||||
const templateMarkers = [
|
||||
'Steps to reproduce',
|
||||
'Expected behavior',
|
||||
'Actual behavior',
|
||||
'Describe the problem',
|
||||
'Problem / pain point',
|
||||
'Proposed solution',
|
||||
'Operating system',
|
||||
];
|
||||
const hasTemplateStructure = templateMarkers.some((marker) =>
|
||||
body.includes(marker)
|
||||
);
|
||||
if (!hasTemplateStructure) {
|
||||
errors.push(
|
||||
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
|
||||
);
|
||||
}
|
||||
|
||||
const labels = new Set(
|
||||
(issue.labels || []).map((label) =>
|
||||
typeof label === 'string' ? label : label.name
|
||||
)
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
if (
|
||||
issue.state === 'closed' &&
|
||||
labels.has('invalid-format')
|
||||
) {
|
||||
labels.delete('invalid-format');
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'open',
|
||||
labels: [...labels],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
|
||||
});
|
||||
}
|
||||
core.info('Issue format OK');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = issue.number;
|
||||
const marker = '<!-- issue-format-bot -->';
|
||||
const bodyText = [
|
||||
marker,
|
||||
'## Issue format check failed',
|
||||
'',
|
||||
'This issue was closed automatically because it does not follow the required format.',
|
||||
'',
|
||||
...errors.map((e) => `- ${e}`),
|
||||
'',
|
||||
'### How to resubmit',
|
||||
'',
|
||||
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
|
||||
'2. Pick **Bug Report** or **Feature Request**',
|
||||
'3. Fill in every required field',
|
||||
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
|
||||
'',
|
||||
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
|
||||
'',
|
||||
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const alreadyNotified = comments.some((c) =>
|
||||
(c.body || '').includes(marker)
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
labels.add('invalid-format');
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
labels: [...labels],
|
||||
});
|
||||
131
App.tsx
131
App.tsx
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
@@ -28,9 +27,7 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -60,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() {
|
||||
|
||||
161
application/app/AppActiveTabChrome.tsx
Normal file
161
application/app/AppActiveTabChrome.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'История сессий',
|
||||
|
||||
@@ -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': 'Выберите тему',
|
||||
|
||||
@@ -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. Настройки конкретного хоста имеют приоритет.',
|
||||
|
||||
@@ -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': '会话历史',
|
||||
|
||||
@@ -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-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '列表视图',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
|
||||
const getTabsSnapshot = () => editorTabStore.getTabs();
|
||||
|
||||
export const useEditorTabs = (): readonly EditorTab[] =>
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
|
||||
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
|
||||
|
||||
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
|
||||
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
|
||||
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
36
application/state/hostTreeInlineGroupDeleteStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupDeleteStore {
|
||||
private targetPath: string | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getTargetPath = () => this.targetPath;
|
||||
|
||||
open = (groupPath: string) => {
|
||||
this.targetPath = groupPath;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
close = () => {
|
||||
if (!this.targetPath) return;
|
||||
this.targetPath = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
|
||||
|
||||
export const useHostTreeInlineGroupDeleteTarget = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupDeleteStore.subscribe,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
hostTreeInlineGroupDeleteStore.getTargetPath,
|
||||
);
|
||||
};
|
||||
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
52
application/state/hostTreeInlineGroupEditStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineGroupEdit = {
|
||||
groupPath: string;
|
||||
initialName: string;
|
||||
isNew: boolean;
|
||||
shouldScrollIntoView?: boolean;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineGroupEditStore {
|
||||
private edit: HostTreeInlineGroupEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineGroupEdit) => {
|
||||
this.edit = {
|
||||
...edit,
|
||||
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
|
||||
};
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
markScrollHandled = () => {
|
||||
if (!this.edit?.shouldScrollIntoView) return;
|
||||
this.edit = { ...this.edit, shouldScrollIntoView: false };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
|
||||
|
||||
export const useHostTreeInlineGroupEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineGroupEditStore.subscribe,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
hostTreeInlineGroupEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
32
application/state/immersiveStore.ts
Normal file
32
application/state/immersiveStore.ts
Normal 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);
|
||||
}
|
||||
@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
|
||||
return useSyncExternalStore(
|
||||
sessionActivityStore.subscribe,
|
||||
sessionActivityStore.getSnapshot,
|
||||
sessionActivityStore.getSnapshot,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
|
||||
|
||||
export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
|
||||
|
||||
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
|
||||
options?.onFileWatchSynced?.(payload);
|
||||
optionsRef.current?.onFileWatchSynced?.(payload);
|
||||
});
|
||||
|
||||
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
|
||||
options?.onFileWatchError?.(payload);
|
||||
optionsRef.current?.onFileWatchError?.(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
};
|
||||
}, [options]);
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
});
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
|
||||
76
application/state/terminalHostTreeStore.ts
Normal file
76
application/state/terminalHostTreeStore.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
16
application/state/terminalLayoutSuppressStore.test.ts
Normal file
16
application/state/terminalLayoutSuppressStore.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.begin();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), true);
|
||||
terminalLayoutSuppressStore.end();
|
||||
assert.equal(terminalLayoutSuppressStore.getActive(), false);
|
||||
});
|
||||
40
application/state/terminalLayoutSuppressStore.ts
Normal file
40
application/state/terminalLayoutSuppressStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let suppressDepth = 0;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const terminalLayoutSuppressStore = {
|
||||
getActive: () => suppressDepth > 0,
|
||||
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
begin: () => {
|
||||
suppressDepth += 1;
|
||||
emit();
|
||||
},
|
||||
|
||||
end: () => {
|
||||
const wasActive = suppressDepth > 0;
|
||||
suppressDepth = Math.max(0, suppressDepth - 1);
|
||||
if (wasActive) {
|
||||
emit();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function useTerminalLayoutSuppressActive(): boolean {
|
||||
return useSyncExternalStore(
|
||||
terminalLayoutSuppressStore.subscribe,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
terminalLayoutSuppressStore.getActive,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ function getBridge(): NetcattyBridge | undefined {
|
||||
export function useAgentDiscovery(
|
||||
externalAgents: ExternalAgentConfig[],
|
||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
@@ -32,10 +34,28 @@ export function useAgentDiscovery(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Discover on mount
|
||||
useEffect(() => {
|
||||
discover();
|
||||
}, [discover]);
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
const runDiscover = () => {
|
||||
if (!cancelled) void discover();
|
||||
};
|
||||
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(runDiscover, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [discover, enabled]);
|
||||
|
||||
// Auto-update args for already-configured discovered agents when
|
||||
// the canonical args from discovery change (e.g. after an app update).
|
||||
|
||||
@@ -109,11 +109,16 @@ interface RemoteVersionCheckOptions {
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const inspectFailureToastShownRef = useRef(false);
|
||||
/** True once checkRemoteVersion has completed (success or failure). Until
|
||||
* this is set, the debounced auto-sync effect will not fire, preventing
|
||||
* an empty local vault from racing ahead and overwriting a non-empty
|
||||
@@ -513,7 +518,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
});
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
@@ -521,7 +526,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -555,7 +560,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} else if (!roundTripFullySynced) {
|
||||
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,7 +595,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
@@ -637,14 +642,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
|
||||
// Surface a degraded-sync hint once per session. Retries and
|
||||
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
|
||||
inspectFailureToastShownRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
tRef.current('sync.autoSync.inspectFailedMessage'),
|
||||
tRef.current('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
@@ -677,7 +681,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
// `t` is read through tRef so locale updates don't rebuild this
|
||||
// callback and re-fire the startup retry effect on unrelated renders.
|
||||
}, []);
|
||||
|
||||
const checkRemoteVersionRef = useRef(checkRemoteVersion);
|
||||
useEffect(() => {
|
||||
checkRemoteVersionRef.current = checkRemoteVersion;
|
||||
}, [checkRemoteVersion]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -789,7 +800,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
const notifyOnFailure = attempt === 0;
|
||||
await checkRemoteVersionRef.current(
|
||||
notifyOnFailure ? undefined : { notifyOnFailure: false },
|
||||
);
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
@@ -824,7 +838,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
@@ -20,28 +20,40 @@ export const useTreeExpandedState = (storageKey: string) => {
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
const togglePath = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
const expandAll = useCallback((allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const collapseAll = () => {
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensurePathExpanded = useCallback((path: string) => {
|
||||
setExpandedPaths((current) => {
|
||||
if (current.has(path)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(path);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
ensurePathExpanded,
|
||||
};
|
||||
};
|
||||
@@ -109,6 +109,29 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
|
||||
* connection logs before persisting. `terminalData` is the full terminal
|
||||
* scrollback for a session; with up to 500 logs it grew the
|
||||
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
|
||||
* add/update re-serialized + wrote the whole thing synchronously
|
||||
* (50–73 ms on the main thread), causing freezes on connect/disconnect.
|
||||
*
|
||||
* The full `terminalData` stays in the in-memory React state (so in-session
|
||||
* replay still works); only explicitly *saved* logs keep it on disk. This
|
||||
* keeps the persisted blob small and writes fast.
|
||||
*/
|
||||
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
|
||||
let changed = false;
|
||||
const next = logs.map((log) => {
|
||||
if (log.saved || log.terminalData === undefined) return log;
|
||||
changed = true;
|
||||
const { terminalData: _omitted, ...rest } = log;
|
||||
return rest;
|
||||
});
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -318,7 +341,7 @@ export const useVaultState = () => {
|
||||
const final = [...updated, ...savedLogs].sort(
|
||||
(a, b) => b.startTime - a.startTime
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
|
||||
return final;
|
||||
});
|
||||
return newLog.id;
|
||||
@@ -332,7 +355,7 @@ export const useVaultState = () => {
|
||||
const updated = prev.map((log) =>
|
||||
log.id === id ? { ...log, ...updates } : log
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
@@ -360,7 +383,7 @@ export const useVaultState = () => {
|
||||
const clearUnsavedConnectionLogs = useCallback(() => {
|
||||
setConnectionLogs((prev) => {
|
||||
const saved = prev.filter((log) => log.saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
|
||||
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
|
||||
return saved;
|
||||
});
|
||||
}, []);
|
||||
|
||||
46
application/state/vaultHostTreeActionsStore.ts
Normal file
46
application/state/vaultHostTreeActionsStore.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from './ui/context-menu';
|
||||
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
};
|
||||
|
||||
interface ScriptsSidePanelProps {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
@@ -69,6 +76,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// Normalize the package list + derive ancestor packages implied by each path
|
||||
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
|
||||
const normalizedPackages = useMemo(() => {
|
||||
if (!isVisible) return new Set<string>();
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (raw: string) => {
|
||||
const path = raw.trim();
|
||||
@@ -87,7 +95,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (s.package) addWithAncestors(s.package);
|
||||
});
|
||||
return set;
|
||||
}, [packages, snippets]);
|
||||
}, [packages, snippets, isVisible]);
|
||||
|
||||
// Track every package we've ever observed so we can tell "new" from
|
||||
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
|
||||
@@ -99,6 +107,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
// everything without drilling in. After that, respect the user's collapse
|
||||
// choices across unrelated refreshes.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const seen = seenPackagesRef.current;
|
||||
const newlySeen: string[] = [];
|
||||
normalizedPackages.forEach((p) => {
|
||||
@@ -110,10 +119,53 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
if (newlySeen.length === 0) return;
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
newlySeen.forEach((p) => next.add(p));
|
||||
// Only auto-expand root packages on first sight — expanding the full
|
||||
// tree upfront was freezing the panel on large snippet libraries.
|
||||
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
|
||||
return next;
|
||||
});
|
||||
}, [normalizedPackages]);
|
||||
}, [normalizedPackages, isVisible]);
|
||||
|
||||
const snippetIndex = useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return {
|
||||
snippetsByPackage: new Map<string, Snippet[]>(),
|
||||
descendantCountByPackage: new Map<string, number>(),
|
||||
};
|
||||
}
|
||||
const snippetsByPackage = new Map<string, Snippet[]>();
|
||||
const descendantCountByPackage = new Map<string, number>();
|
||||
|
||||
const bumpCount = (path: string) => {
|
||||
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
|
||||
};
|
||||
|
||||
for (const snippet of snippets) {
|
||||
const pkg = snippet.package || '';
|
||||
const bucket = snippetsByPackage.get(pkg);
|
||||
if (bucket) bucket.push(snippet);
|
||||
else snippetsByPackage.set(pkg, [snippet]);
|
||||
|
||||
if (pkg === '') {
|
||||
bumpCount('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = pkg;
|
||||
while (true) {
|
||||
bumpCount(path);
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash < 0) break;
|
||||
path = path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of snippetsByPackage.values()) {
|
||||
bucket.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return { snippetsByPackage, descendantCountByPackage };
|
||||
}, [snippets, isVisible]);
|
||||
|
||||
const togglePackage = useCallback((path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
@@ -126,6 +178,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
|
||||
// When search is active, flatten everything (no tree, no packages).
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!isVisible) return null;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
return snippets.filter(
|
||||
@@ -133,9 +186,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
s.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
}, [snippets, search, isVisible]);
|
||||
|
||||
const rows = useMemo<TreeRow[]>(() => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) return [];
|
||||
|
||||
const out: TreeRow[] = [];
|
||||
@@ -159,15 +213,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
};
|
||||
|
||||
const snippetsIn = (pkg: string | null): Snippet[] =>
|
||||
snippets
|
||||
.filter((s) => (s.package || '') === (pkg ?? ''))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const countDescendants = (pkg: string): number =>
|
||||
snippets.filter((s) => {
|
||||
const sp = s.package || '';
|
||||
return sp === pkg || sp.startsWith(pkg + '/');
|
||||
}).length;
|
||||
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
|
||||
|
||||
const walk = (pkg: string, depth: number) => {
|
||||
const children = childPackagesOf(pkg);
|
||||
@@ -181,7 +227,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
path: pkg,
|
||||
name: pkgDisplayName(pkg),
|
||||
depth,
|
||||
count: countDescendants(pkg),
|
||||
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
});
|
||||
@@ -200,7 +246,38 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
childPackagesOf(null).forEach((root) => walk(root, 0));
|
||||
|
||||
return out;
|
||||
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
|
||||
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
|
||||
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
|
||||
|
||||
const listItems = useMemo((): ScriptsListItem[] => {
|
||||
if (!isVisible) return [];
|
||||
if (searchMatches !== null) {
|
||||
return searchMatches.map((snippet) => ({
|
||||
key: `search:${snippet.id}`,
|
||||
kind: 'search',
|
||||
snippet,
|
||||
}));
|
||||
}
|
||||
return rows.flatMap((row): ScriptsListItem[] => {
|
||||
if (row.type === 'package') {
|
||||
return [{
|
||||
key: `pkg:${row.id}`,
|
||||
kind: 'package',
|
||||
row,
|
||||
countLabel: t('snippets.package.count', { count: row.count }),
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
key: `snip:${row.id}`,
|
||||
kind: 'snippet',
|
||||
row,
|
||||
}];
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
@@ -265,62 +342,62 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="py-1">
|
||||
{!hasAnyContent && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search flat list */}
|
||||
{searchMatches !== null && searchMatches.length > 0 &&
|
||||
searchMatches.map((s) => (
|
||||
<SnippetRow
|
||||
key={s.id}
|
||||
snippet={s}
|
||||
depth={0}
|
||||
subtitle={s.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(s)}
|
||||
onEdit={() => handleEditSnippet(s)}
|
||||
onDelete={() => handleDeleteSnippet(s.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Tree */}
|
||||
{searchMatches === null &&
|
||||
rows.map((row) =>
|
||||
row.type === 'package' ? (
|
||||
<PackageRow
|
||||
key={`pkg:${row.id}`}
|
||||
row={row}
|
||||
countLabel={t('snippets.package.count', { count: row.count })}
|
||||
onToggle={() => togglePackage(row.path)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0">
|
||||
{!hasAnyContent ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Zap size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
|
||||
</div>
|
||||
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
contentClassName="py-1"
|
||||
items={listItems}
|
||||
itemHeight={SCRIPT_ROW_HEIGHT}
|
||||
getItemKey={(item) => item.key}
|
||||
renderItem={(item) => {
|
||||
if (item.kind === 'search') {
|
||||
return (
|
||||
<SnippetRow
|
||||
snippet={item.snippet}
|
||||
depth={0}
|
||||
subtitle={item.snippet.package || t('terminal.toolbar.library')}
|
||||
onClick={() => handleSnippetClick(item.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'package') {
|
||||
return (
|
||||
<PackageRow
|
||||
row={item.row}
|
||||
countLabel={item.countLabel}
|
||||
onToggle={() => togglePackage(item.row.path)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SnippetRow
|
||||
key={`snip:${row.id}`}
|
||||
snippet={row.snippet}
|
||||
depth={row.depth}
|
||||
onClick={() => handleSnippetClick(row.snippet)}
|
||||
onEdit={() => handleEditSnippet(row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(row.snippet.id)}
|
||||
snippet={item.row.snippet}
|
||||
depth={item.row.depth}
|
||||
onClick={() => handleSnippetClick(item.row.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
@@ -332,7 +409,7 @@ interface PackageRowProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
|
||||
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@@ -351,7 +428,8 @@ const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) =>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
|
||||
</button>
|
||||
);
|
||||
));
|
||||
PackageRow.displayName = 'PackageRow';
|
||||
|
||||
interface SnippetRowProps {
|
||||
snippet: Snippet;
|
||||
@@ -364,7 +442,7 @@ interface SnippetRowProps {
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
const SnippetRow = memo<SnippetRowProps>(({
|
||||
snippet,
|
||||
depth,
|
||||
subtitle,
|
||||
@@ -415,7 +493,8 @@ const SnippetRow: React.FC<SnippetRowProps> = ({
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
|
||||
@@ -343,7 +343,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-8 w-8 rounded-md"
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Tooltip>
|
||||
|
||||
@@ -16,28 +16,49 @@ type AppInfo = {
|
||||
};
|
||||
|
||||
const REPO_URL = "https://github.com/binaricat/Netcatty";
|
||||
const BUG_REPORT_TEMPLATE = "bug_report.yml";
|
||||
|
||||
const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const title = "Bug: ";
|
||||
const bodyLines = [
|
||||
"## Describe the problem",
|
||||
"",
|
||||
"## Steps to reproduce",
|
||||
"1.",
|
||||
"",
|
||||
"## Expected behavior",
|
||||
"",
|
||||
"## Actual behavior",
|
||||
"",
|
||||
"## Environment",
|
||||
`- App: ${appInfo.name} ${appInfo.version}`,
|
||||
`- Platform: ${appInfo.platform || "unknown"}`,
|
||||
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
|
||||
];
|
||||
const mapIssuePlatform = (platform?: string) => {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
|
||||
export const buildIssueUrl = (appInfo: AppInfo) => {
|
||||
const params = new URLSearchParams({
|
||||
title,
|
||||
body: bodyLines.join("\n"),
|
||||
template: BUG_REPORT_TEMPLATE,
|
||||
title: "[Bug] ",
|
||||
});
|
||||
|
||||
if (appInfo.version) {
|
||||
params.set("version", appInfo.version);
|
||||
}
|
||||
|
||||
const platform = mapIssuePlatform(appInfo.platform);
|
||||
if (platform) {
|
||||
params.set("platform", platform);
|
||||
}
|
||||
|
||||
const installSource =
|
||||
appInfo.version === "0.0.0"
|
||||
? "Built from source (npm run dev / pack)"
|
||||
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
|
||||
params.set("install_source", installSource);
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
|
||||
params.set(
|
||||
"logs",
|
||||
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
|
||||
);
|
||||
|
||||
return `${REPO_URL}/issues/new?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
38
components/SftpSidePanelDeferredMount.tsx
Normal file
38
components/SftpSidePanelDeferredMount.tsx
Normal 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}</>;
|
||||
};
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
22
components/TopTabs.test.ts
Normal file
22
components/TopTabs.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
98
components/WindowOpacityButton.tsx
Normal file
98
components/WindowOpacityButton.tsx
Normal 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;
|
||||
@@ -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}>
|
||||
|
||||
152
components/ai/cattyHistoryReplay.test.ts
Normal file
152
components/ai/cattyHistoryReplay.test.ts
Normal 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);
|
||||
});
|
||||
138
components/ai/cattyHistoryReplay.ts
Normal file
138
components/ai/cattyHistoryReplay.ts
Normal 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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
components/ai/scopedHistorySessions.ts
Normal file
59
components/ai/scopedHistorySessions.ts
Normal 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;
|
||||
}
|
||||
72
components/customCssHooks.test.tsx
Normal file
72
components/customCssHooks.test.tsx
Normal 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)));
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
84
components/host/HostTreeContextMenus.tsx
Normal file
84
components/host/HostTreeContextMenus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
components/host/HostTreeGroupDeleteDialog.tsx
Normal file
96
components/host/HostTreeGroupDeleteDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
components/host/HostTreeGroupInlineRenameInput.tsx
Normal file
69
components/host/HostTreeGroupInlineRenameInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
35
components/sftp/sftpFollowTerminalCwd.test.ts
Normal file
35
components/sftp/sftpFollowTerminalCwd.test.ts
Normal 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);
|
||||
});
|
||||
24
components/sftp/sftpFollowTerminalCwd.ts
Normal file
24
components/sftp/sftpFollowTerminalCwd.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
360
components/terminal/TerminalServerStats.tsx
Normal file
360
components/terminal/TerminalServerStats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user