Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ec02dcae | ||
|
|
e75c654a1a | ||
|
|
29b1eca1fd | ||
|
|
e2d036e710 | ||
|
|
094f0abe4a | ||
|
|
8ab2003dae | ||
|
|
b1b0c5648c | ||
|
|
36e5779d94 | ||
|
|
53aef452cc | ||
|
|
3ef5a64b94 | ||
|
|
c28db932a4 | ||
|
|
f2c2501fa5 | ||
|
|
b1f930a995 | ||
|
|
de60b616cd | ||
|
|
6e6a0240a7 | ||
|
|
2e2360a9fc | ||
|
|
8011f4e2e8 | ||
|
|
970037682c | ||
|
|
42b58efc5c | ||
|
|
b20163d762 | ||
|
|
e0a56cbb14 | ||
|
|
8dae851ea3 | ||
|
|
03ba9595c0 | ||
|
|
4b07b4826a | ||
|
|
80d9b33c59 | ||
|
|
3be3c14912 | ||
|
|
4171f85c73 | ||
|
|
5a78ebcf7c | ||
|
|
9294a7130f | ||
|
|
9ce3abc2b4 | ||
|
|
327594a598 | ||
|
|
31cccdec03 | ||
|
|
29a6172120 | ||
|
|
06486e06dd | ||
|
|
ada55ab461 | ||
|
|
a9e4de65a9 | ||
|
|
2867262e4d | ||
|
|
779c09186c | ||
|
|
6a0408b942 | ||
|
|
43e094c345 | ||
|
|
7d30b19421 | ||
|
|
e9e8c35178 |
118
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
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],
|
||||
});
|
||||
123
App.tsx
123
App.tsx
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useImmersiveMode } from './application/state/useImmersiveMode';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
@@ -28,9 +27,7 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
mergeTerminalHostUpdate,
|
||||
resolveHostTerminalThemeId,
|
||||
} from './domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -60,18 +57,24 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
|
||||
import { PassphraseRequest } from './components/PassphraseModal';
|
||||
import { classifyLocalShellType } from './lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
|
||||
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
|
||||
import { AppView } from './application/app/AppView';
|
||||
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
|
||||
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
|
||||
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
|
||||
|
||||
// Initialize fonts eagerly at app startup
|
||||
initializeFonts();
|
||||
initializeUIFonts();
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
sourceSession?: TerminalSession;
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
@@ -100,6 +103,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -125,12 +129,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
@@ -244,6 +251,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
} = useSessionState();
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
@@ -261,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])),
|
||||
@@ -290,59 +291,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
|
||||
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
|
||||
const resolveTheme = (s: TerminalSession): TerminalTheme => {
|
||||
let baseTheme: TerminalTheme;
|
||||
// When "Follow Application Theme" is on, the UI-matched terminal
|
||||
// theme overrides everything — including per-host theme overrides.
|
||||
// This ensures all terminals match the app chrome regardless of
|
||||
// individual host settings.
|
||||
if (followAppTerminalTheme) {
|
||||
baseTheme = currentTerminalTheme;
|
||||
} else {
|
||||
const host = hostById.get(s.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
baseTheme = themeById.get(themeId) || currentTerminalTheme;
|
||||
}
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onOpenSessionInNewWindow) return undefined;
|
||||
return bridge.onOpenSessionInNewWindow((payload) => {
|
||||
if (!payload?.sourceSession) return;
|
||||
setPendingNewWindowSession(payload);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Workspace
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
// Focus mode: use the focused (or first remaining) session's theme
|
||||
if (workspace.viewMode === 'focus') {
|
||||
const wsSessionIds = collectSessionIds(workspace.root);
|
||||
const focused = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focused ? resolveTheme(focused) : null;
|
||||
}
|
||||
// Split mode: require all sessions to share the same theme
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
const wsSessions = sessionIds
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (wsSessions.length === 0) return null;
|
||||
const firstTheme = resolveTheme(wsSessions[0]);
|
||||
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
// Single session tab
|
||||
const session = sessionById.get(activeTabId);
|
||||
if (!session) return null;
|
||||
return resolveTheme(session);
|
||||
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
|
||||
|
||||
useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
|
||||
createSessionFromCloneSource(pendingNewWindowSession.sourceSession, {
|
||||
localShellType: pendingNewWindowSession.localShellType,
|
||||
});
|
||||
setPendingNewWindowSession(null);
|
||||
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
@@ -714,6 +681,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessionId, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells]);
|
||||
|
||||
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
|
||||
|
||||
const closeTabKeyStr = useMemo(() => {
|
||||
if (hotkeyScheme === 'disabled') return null;
|
||||
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
|
||||
@@ -755,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),
|
||||
@@ -773,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?.(() => {
|
||||
@@ -985,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, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />;
|
||||
return (
|
||||
<>
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
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() {
|
||||
|
||||
153
application/AppHandlers.newWindow.test.ts
Normal file
153
application/AppHandlers.newWindow.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { TerminalSession } from "../domain/models";
|
||||
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
|
||||
|
||||
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
id: "session-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "Prod SSH",
|
||||
hostname: "prod.example.com",
|
||||
username: "deploy",
|
||||
status: "connected",
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
|
||||
const openedPayloads: unknown[] = [];
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async (payload: unknown) => {
|
||||
openedPayloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(openedPayloads.length, 1);
|
||||
assert.deepEqual(openedPayloads[0], {
|
||||
title: "Prod SSH",
|
||||
sourceSession: sourceSession(),
|
||||
localShellType: "zsh",
|
||||
});
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
|
||||
let called = false;
|
||||
|
||||
await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
called = true;
|
||||
return { success: true };
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
}),
|
||||
"missing-session",
|
||||
);
|
||||
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => ({ success: false }),
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
|
||||
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
|
||||
const errors: string[] = [];
|
||||
|
||||
const result = await copySessionToNewWindowWithCurrentShellImpl(
|
||||
() => ({
|
||||
classifyLocalShellType: () => "zsh",
|
||||
discoveredShells: [],
|
||||
netcattyBridge: {
|
||||
get: () => ({
|
||||
openSessionInNewWindow: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
}),
|
||||
},
|
||||
resolveShellSetting: () => ({ command: "/bin/zsh" }),
|
||||
sessions: [sourceSession()],
|
||||
terminalSettings: { localShell: "system-default" },
|
||||
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
|
||||
toast: {
|
||||
error: (message: string) => errors.push(message),
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(errors, ["Could not open"]);
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, re
|
||||
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
|
||||
const host = hosts.find(h => h.id === session.hostId);
|
||||
if (host) {
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
|
||||
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,36 @@ export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessio
|
||||
}
|
||||
}
|
||||
|
||||
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
|
||||
{
|
||||
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
|
||||
if (!sourceSession) return false;
|
||||
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSessionInNewWindow) {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
try {
|
||||
const result = await bridge.openSessionInNewWindow({
|
||||
title: sourceSession.hostLabel,
|
||||
sourceSession,
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
||||
});
|
||||
const success = result?.success === true;
|
||||
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return success;
|
||||
} catch {
|
||||
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
|
||||
const { netcattyBridge, sessions, t } = getCtx();
|
||||
{
|
||||
|
||||
@@ -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,8 +33,8 @@ type AppViewContext = Record<string, any>;
|
||||
|
||||
export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
const {
|
||||
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
|
||||
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
|
||||
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
|
||||
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
|
||||
@@ -43,10 +44,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
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}
|
||||
@@ -121,6 +128,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -128,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}
|
||||
@@ -214,6 +224,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
@@ -258,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}
|
||||
@@ -267,11 +280,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
||||
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
@@ -298,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,13 +233,26 @@ 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',
|
||||
'ai.chat.attach': 'Attach',
|
||||
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
|
||||
'ai.chat.terminalSelectionLines': 'lines: {count}',
|
||||
'ai.chat.collapse': 'Collapse',
|
||||
'ai.chat.expand': 'Expand',
|
||||
'ai.chat.enableAgent': 'Enable {name}',
|
||||
|
||||
@@ -159,6 +159,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -262,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-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, 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 (not the focus-mode terminal list) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
@@ -437,6 +440,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Renderer',
|
||||
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
|
||||
'settings.terminal.rendering.auto': 'Auto',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
|
||||
@@ -589,6 +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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
@@ -70,6 +71,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.addSelectionToAI': 'Add to Conversation',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
@@ -77,6 +79,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': 'Username',
|
||||
@@ -492,6 +496,8 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
|
||||
@@ -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,13 +233,26 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.hostTree.search': 'Поиск хостов...',
|
||||
'terminal.layer.hostTree.searchButton': 'Поиск',
|
||||
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
|
||||
'terminal.layer.hostTree.newGroup': 'Новая группа',
|
||||
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
|
||||
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
|
||||
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
|
||||
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
|
||||
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
|
||||
'terminal.layer.hostTree.empty': 'Хосты не найдены',
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.toggleTheme': 'Переключить тему',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
'ai.chat.attach': 'Прикрепить',
|
||||
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
|
||||
'ai.chat.terminalSelectionLines': 'строк: {count}',
|
||||
'ai.chat.collapse': 'Свернуть',
|
||||
'ai.chat.expand': 'Развернуть',
|
||||
'ai.chat.enableAgent': 'Включить {name}',
|
||||
|
||||
@@ -159,6 +159,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
|
||||
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -262,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-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, 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 (не список терминалов Focus) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
@@ -437,6 +440,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': 'Рендерер',
|
||||
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
|
||||
'settings.terminal.rendering.auto': 'Авто',
|
||||
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
|
||||
|
||||
// Settings > Terminal > Workspace Focus Indicator
|
||||
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
@@ -91,6 +92,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.addSelectionToAI': 'Добавить в чат',
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
@@ -98,6 +100,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
'terminal.auth.sshKey': 'SSH-ключ',
|
||||
'terminal.auth.username': 'Имя пользователя',
|
||||
@@ -507,6 +511,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
|
||||
@@ -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,13 +233,26 @@ 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': '会话历史',
|
||||
'ai.chat.attach': '附件',
|
||||
'ai.chat.terminalSelectionAttachment': '终端选区',
|
||||
'ai.chat.terminalSelectionLines': '{count} 行',
|
||||
'ai.chat.collapse': '收起',
|
||||
'ai.chat.expand': '展开',
|
||||
'ai.chat.enableAgent': '启用 {name}',
|
||||
|
||||
@@ -143,6 +143,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
|
||||
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
@@ -246,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-side-panel(SFTP/脚本/主题/AI 侧栏)、terminal-sftp-panel、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 / 操作侧栏边框(不是 Focus 模式终端列表) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
@@ -365,6 +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': '创建根分组',
|
||||
@@ -611,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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
@@ -79,6 +80,11 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'settings.sftp.followTerminalCwd': '追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
|
||||
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
|
||||
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
|
||||
|
||||
'settings.sftp.defaultViewMode': '默认视图模式',
|
||||
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
|
||||
'settings.sftp.defaultViewMode.list': '列表视图',
|
||||
@@ -286,6 +292,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.rendering.renderer': '渲染器',
|
||||
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
|
||||
'settings.terminal.rendering.auto': '自动',
|
||||
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
|
||||
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
|
||||
|
||||
// Settings > Terminal > Autocomplete
|
||||
'settings.terminal.section.autocomplete': '自动补全',
|
||||
@@ -479,6 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
|
||||
@@ -279,6 +279,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
@@ -286,6 +287,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
'terminal.auth.sshKey': 'SSH Key',
|
||||
'terminal.auth.username': '用户名',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
@@ -32,9 +34,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;
|
||||
@@ -52,13 +59,16 @@ interface UseSettingsIpcSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -80,13 +90,16 @@ export function useSettingsIpcSync({
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
@@ -167,6 +180,9 @@ export function useSettingsIpcSync({
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsTimestampsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -185,12 +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));
|
||||
@@ -217,13 +240,16 @@ export function useSettingsIpcSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
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;
|
||||
@@ -63,6 +70,7 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
// Session Logs defaults
|
||||
export const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
|
||||
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
|
||||
|
||||
export const readStoredString = (key: string): string | null => {
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -38,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,
|
||||
@@ -66,6 +70,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
sftpFollowTerminalCwd: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
@@ -74,9 +79,11 @@ interface UseSettingsStorageSyncParams {
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -97,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>>;
|
||||
@@ -105,8 +113,10 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -119,19 +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, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
@@ -142,20 +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, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -305,6 +315,12 @@ export function useSettingsStorageSync({
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sessionLogsTimestampsEnabled) {
|
||||
setSessionLogsTimestampsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sshDebugLogsEnabled) {
|
||||
@@ -325,6 +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) {
|
||||
@@ -363,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') {
|
||||
@@ -391,13 +419,16 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsFormat,
|
||||
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,
|
||||
);
|
||||
}
|
||||
52
application/state/terminalSelectionAttachment.test.ts
Normal file
52
application/state/terminalSelectionAttachment.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment creates a compact terminal log attachment", () => {
|
||||
const attachment = createTerminalSelectionAttachment("line one\nline two");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(attachment.mediaType, TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE);
|
||||
assert.match(attachment.filename, /^terminal-selection-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.log$/);
|
||||
assert.equal(attachment.terminalSelection, true);
|
||||
assert.equal(attachment.previewText, "line one");
|
||||
assert.equal(attachment.lineCount, 2);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "line one\nline two");
|
||||
});
|
||||
|
||||
test("createTerminalSelectionAttachment preserves utf-8 terminal output", () => {
|
||||
const attachment = createTerminalSelectionAttachment("错误: 权限不足\n路径: /tmp/测试");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(decodeTerminalSelectionAttachment(attachment), "错误: 权限不足\n路径: /tmp/测试");
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments expands terminal selections into prompt text", () => {
|
||||
const attachment = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("帮我看看", [attachment]),
|
||||
`帮我看看\n\n[Terminal selection: ${attachment.filename}]\ndocker ps -a\npermission denied`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts", () => {
|
||||
const attachment = createTerminalSelectionAttachment("systemctl status nginx");
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("", [attachment]),
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
101
application/state/terminalSelectionAttachment.ts
Normal file
101
application/state/terminalSelectionAttachment.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
const MAX_PREVIEW_CHARS = 80;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToText(base64Data: string): string {
|
||||
const binary = atob(base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function buildTimestamp(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function getPreviewText(text: string): string {
|
||||
const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
||||
return firstLine.length > MAX_PREVIEW_CHARS
|
||||
? `${firstLine.slice(0, MAX_PREVIEW_CHARS - 1)}...`
|
||||
: firstLine;
|
||||
}
|
||||
|
||||
export function createTerminalSelectionAttachment(
|
||||
selection: string,
|
||||
now: Date = new Date(),
|
||||
): UploadedFile | null {
|
||||
const text = selection.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const base64Data = bytesToBase64(new TextEncoder().encode(text));
|
||||
const filename = `terminal-selection-${buildTimestamp(now)}.log`;
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
filename,
|
||||
dataUrl: `data:${TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE};base64,${base64Data}`,
|
||||
base64Data,
|
||||
mediaType: TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
|
||||
terminalSelection: true,
|
||||
previewText: getPreviewText(text),
|
||||
lineCount: text.split(/\r?\n/).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "base64Data" | "terminalSelection">,
|
||||
): string | null {
|
||||
if (!attachment.terminalSelection) return null;
|
||||
return base64ToText(attachment.base64Data);
|
||||
}
|
||||
|
||||
export function isTerminalSelectionAttachment(
|
||||
attachment: Pick<UploadedFile | ChatMessageAttachment, "terminalSelection">,
|
||||
): boolean {
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
): string {
|
||||
const terminalBlocks = attachments
|
||||
.filter(isTerminalSelectionAttachment)
|
||||
.map((attachment, index) => {
|
||||
const text = decodeTerminalSelectionAttachment(attachment);
|
||||
if (!text) return null;
|
||||
const label = attachment.filename || `terminal-selection-${index + 1}.log`;
|
||||
return `\n\n[Terminal selection: ${label}]\n${text}`;
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -820,6 +820,25 @@ export const useSessionState = () => {
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
const newSessionId = crypto.randomUUID();
|
||||
const newSession = createCopiedTerminalSessionClone(sourceSession, {
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
});
|
||||
delete newSession.workspaceId;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
|
||||
setActiveTabId(newSessionId);
|
||||
return newSessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
setBroadcastWorkspaceIds(prev => {
|
||||
@@ -946,5 +965,6 @@ export const useSessionState = () => {
|
||||
closeLogView,
|
||||
// Copy session
|
||||
copySession,
|
||||
createSessionFromCloneSource,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,16 +25,19 @@ import {
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
|
||||
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -69,7 +72,9 @@ import {
|
||||
DEFAULT_LIGHT_UI_THEME,
|
||||
DEFAULT_SESSION_LOGS_ENABLED,
|
||||
DEFAULT_SESSION_LOGS_FORMAT,
|
||||
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
|
||||
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
|
||||
DEFAULT_SFTP_AUTO_SYNC,
|
||||
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
|
||||
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
@@ -81,6 +86,8 @@ import {
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
@@ -202,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;
|
||||
@@ -242,6 +253,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
const [sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED;
|
||||
});
|
||||
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
|
||||
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
|
||||
@@ -272,6 +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);
|
||||
@@ -483,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);
|
||||
@@ -564,13 +596,16 @@ export const useSettingsState = () => {
|
||||
setSessionLogsEnabled,
|
||||
setSessionLogsDir,
|
||||
setSessionLogsFormat,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
setHotkeyScheme,
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState,
|
||||
@@ -598,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, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -766,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);
|
||||
@@ -792,6 +834,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled);
|
||||
}, [sessionLogsTimestampsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -802,6 +850,7 @@ export const useSettingsState = () => {
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -935,6 +984,8 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
sftpDefaultViewMode,
|
||||
setSftpDefaultViewMode,
|
||||
showRecentHosts,
|
||||
@@ -959,6 +1010,8 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
@@ -971,6 +1024,8 @@ export const useSettingsState = () => {
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
@@ -982,9 +1037,9 @@ 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, sshDebugLogsEnabled,
|
||||
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,
|
||||
@@ -190,7 +191,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
@@ -220,6 +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,14 +29,19 @@ import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
isAIChatSessionStreaming,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
@@ -45,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,
|
||||
@@ -130,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];
|
||||
@@ -197,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;
|
||||
@@ -338,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) => {
|
||||
@@ -452,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);
|
||||
@@ -474,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;
|
||||
@@ -522,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;
|
||||
|
||||
@@ -650,18 +664,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
if (!trimmed || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
terminalSelection: file.terminalSelection,
|
||||
previewText: file.previewText,
|
||||
lineCount: file.lineCount,
|
||||
}));
|
||||
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
|
||||
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
|
||||
if (sendAgentId !== 'catty' && !agentConfig) return;
|
||||
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
@@ -691,7 +711,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
|
||||
|
||||
if (!isExternalAgent && !sendActiveProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -701,7 +725,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
|
||||
if (!isExternalAgent && !sendActiveModelId.trim()) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
@@ -741,7 +769,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
@@ -765,7 +793,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider: sendActiveProvider,
|
||||
activeModelId: sendActiveModelId,
|
||||
scopeType,
|
||||
@@ -778,7 +806,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
titleText: trimmed,
|
||||
}, modelAttachments.length > 0 ? modelAttachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
@@ -847,8 +876,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
@@ -900,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) => {
|
||||
|
||||
@@ -45,7 +45,8 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => (
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
icon={<MapPin size={14} className="text-muted-foreground" />}
|
||||
@@ -732,4 +733,5 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -366,6 +368,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={settings.sessionLogsTimestampsEnabled}
|
||||
setSessionLogsTimestampsEnabled={settings.setSessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
|
||||
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
|
||||
@@ -108,15 +108,13 @@ test("clipboard files become path-backed upload entries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("clipboard upload ignores directories until recursive paste is supported", () => {
|
||||
test("clipboard upload keeps directories for recursive folder paste", () => {
|
||||
const files: ClipboardLocalFile[] = [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
|
||||
];
|
||||
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
|
||||
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
|
||||
]);
|
||||
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
|
||||
});
|
||||
|
||||
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {
|
||||
|
||||
@@ -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,8 +837,7 @@ 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 && (
|
||||
@@ -728,13 +883,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 +964,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 +979,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()}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { detectLocalOs } from "../lib/localShell";
|
||||
@@ -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";
|
||||
@@ -56,14 +57,20 @@ import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./terminal/runtime/promptLineBreak";
|
||||
import { recordTerminalCommandExecution } from "./terminal/runtime/terminalCommandExecution";
|
||||
import {
|
||||
prepareSudoAutofillInput,
|
||||
type SudoPasswordAutofill,
|
||||
} from "./terminal/runtime/terminalSudoAutofill";
|
||||
import {
|
||||
recordTerminalCommandExecution,
|
||||
shouldRecordShellHistory,
|
||||
} from "./terminal/runtime/terminalCommandExecution";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
@@ -71,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,
|
||||
@@ -87,6 +94,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
themePreviewId,
|
||||
knownHosts = [],
|
||||
isVisible,
|
||||
paneLayoutKey,
|
||||
inWorkspace,
|
||||
isResizing,
|
||||
isFocusMode,
|
||||
@@ -116,6 +124,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onAddKnownHost,
|
||||
onExpandToFocus,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onOpenSftp,
|
||||
@@ -130,7 +139,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
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();
|
||||
@@ -146,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
|
||||
@@ -215,6 +230,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -232,11 +248,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
|
||||
const [connectionReuseFellBack, setConnectionReuseFellBack] = useState(false);
|
||||
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
|
||||
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
|
||||
sudoAutofillPasswordRef.current = sudoAutofillPassword;
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
currentHop: number;
|
||||
@@ -271,11 +291,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
const prepareProgrammaticSudoInput = useCallback((data: string): string => {
|
||||
if (
|
||||
statusRef.current !== "connected" ||
|
||||
(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
const pastedCommand = data.match(/^([^\r\n]+)(\r\n|\r|\n)$/);
|
||||
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
|
||||
return data;
|
||||
}
|
||||
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
|
||||
autocompleteAcceptTextRef.current = (text: string) => {
|
||||
const id = sessionRef.current;
|
||||
if (id && text) {
|
||||
let textToWrite = text;
|
||||
let handledSubmittedInput = false;
|
||||
if (
|
||||
host.protocol !== "serial" &&
|
||||
statusRef.current === "connected" &&
|
||||
!(isBroadcastEnabledRef.current && onBroadcastInputRef.current)
|
||||
) {
|
||||
const preparedText = prepareProgrammaticSudoInput(text);
|
||||
handledSubmittedInput = preparedText !== text;
|
||||
textToWrite = preparedText;
|
||||
}
|
||||
|
||||
// Serial line mode: buffer text and handle local echo instead of direct send
|
||||
if (host.protocol === "serial" && serialConfig?.lineMode) {
|
||||
for (const ch of text) {
|
||||
@@ -303,7 +350,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// (fall through to shared bookkeeping below — don't return early)
|
||||
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
|
||||
// Serial character mode with local echo: echo accepted text locally
|
||||
terminalBackend.writeToSession(id, text);
|
||||
terminalBackend.writeToSession(id, textToWrite);
|
||||
for (const ch of text) {
|
||||
if (ch === "\r") {
|
||||
writeLocalTerminalData("\r\n");
|
||||
@@ -312,7 +359,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
terminalBackend.writeToSession(id, text);
|
||||
terminalBackend.writeToSession(id, textToWrite);
|
||||
}
|
||||
|
||||
// Broadcast to other sessions if broadcast mode is enabled
|
||||
@@ -322,12 +369,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
// Update command buffer for onCommandExecuted tracking
|
||||
for (const ch of text) {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
if (handledSubmittedInput) {
|
||||
commandBufferRef.current = "";
|
||||
break;
|
||||
} else if (ch === "\r" || ch === "\n") {
|
||||
const rawCommand = commandBufferRef.current;
|
||||
recordTerminalCommandExecution(rawCommand, {
|
||||
host,
|
||||
sessionId,
|
||||
onCommandExecuted,
|
||||
onCommandSubmitted,
|
||||
commandBufferRef,
|
||||
promptLineBreakStateRef,
|
||||
}, termRef.current);
|
||||
@@ -403,20 +454,18 @@ 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);
|
||||
|
||||
const zmodemToastedRef = useRef(false);
|
||||
|
||||
const pendingAuthRef = useRef<PendingAuth>(null);
|
||||
useEffect(() => {
|
||||
sudoAutofillRef.current?.updatePassword(sudoAutofillPassword);
|
||||
}, [sudoAutofillPassword]);
|
||||
const sessionStartersRef = useRef<ReturnType<typeof createTerminalSessionStarters> | null>(null);
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
@@ -560,6 +609,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
isBootActiveRef.current = false;
|
||||
retryTokenRef.current = null;
|
||||
cleanupSession();
|
||||
xtermRuntimeRef.current?.dispose();
|
||||
@@ -585,6 +635,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalBackend,
|
||||
serialConfig,
|
||||
isVisibleRef,
|
||||
isBootActiveRef,
|
||||
pendingOutputScrollRef,
|
||||
sessionRef,
|
||||
hasConnectedRef,
|
||||
@@ -595,6 +646,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serializeAddonRef,
|
||||
pendingAuthRef,
|
||||
promptLineBreakStateRef,
|
||||
sudoAutofillRef,
|
||||
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
setError,
|
||||
@@ -641,6 +694,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
sudoAutofillPasswordRef,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
@@ -740,7 +795,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const executeSnippetCommand = useCallback((
|
||||
command: string,
|
||||
noAutoRun?: boolean,
|
||||
options?: { broadcast?: boolean },
|
||||
) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
@@ -762,14 +821,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// without re-wrapping. Without broadcasting at all, accepting a snippet in
|
||||
// broadcast mode would clear peer input (the clear keystrokes already go
|
||||
// through the broadcast-aware path) but never send the command.
|
||||
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
if (options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
|
||||
onBroadcastInputRef.current(data, sessionId);
|
||||
}
|
||||
|
||||
data = prepareProgrammaticSudoInput(data);
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
@@ -796,15 +856,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const terminalContextActionsRef = useRef(terminalContextActions);
|
||||
terminalContextActionsRef.current = terminalContextActions;
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
const handleAddSelectionToAI = useCallback(() => {
|
||||
const selection = termRef.current?.getSelection() ?? "";
|
||||
if (!selection.trim()) return;
|
||||
onAddSelectionToAI?.(sessionId, selection);
|
||||
}, [onAddSelectionToAI, sessionId]);
|
||||
|
||||
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();
|
||||
@@ -818,7 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setShowSFTP(true);
|
||||
};
|
||||
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
if (pendingHostKeyRequestId) {
|
||||
@@ -987,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}
|
||||
@@ -1004,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"
|
||||
@@ -1021,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, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, 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, 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, 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, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, 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,8 +22,9 @@ 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';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -40,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,
|
||||
@@ -59,13 +57,25 @@ import {
|
||||
filterTabsMap,
|
||||
hasNotifiableTerminalOutput,
|
||||
type PendingSftpUpload,
|
||||
type PendingTerminalSelectionForAI,
|
||||
type SidePanelTab,
|
||||
type SnippetExecutor,
|
||||
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,
|
||||
@@ -106,6 +116,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onReorderWorkspaceSessions,
|
||||
onSplitSession,
|
||||
onConnectToHost,
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
updateHosts,
|
||||
@@ -115,22 +127,29 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
toggleScriptsSidePanelRef,
|
||||
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) {
|
||||
@@ -138,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
|
||||
@@ -147,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);
|
||||
@@ -219,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]);
|
||||
@@ -245,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);
|
||||
@@ -307,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 },
|
||||
);
|
||||
@@ -315,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;
|
||||
|
||||
@@ -323,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<
|
||||
@@ -337,6 +319,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
|
||||
Map<string, PendingSftpUpload>
|
||||
>(new Map());
|
||||
const [pendingTerminalSelectionForAI, setPendingTerminalSelectionForAI] =
|
||||
useState<PendingTerminalSelectionForAI | null>(null);
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
|
||||
@@ -449,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>();
|
||||
@@ -599,6 +553,70 @@ 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) {
|
||||
const rawHost = hostMap.get(session.hostId);
|
||||
if (rawHost) {
|
||||
// Resolve through identity references too (host.identityId), not just
|
||||
// host.password, so a password stored in a Keychain identity is filled
|
||||
// (issue #1284) — same resolution SSH login uses.
|
||||
map.set(session.id, resolveHostAutofillPassword({ host: rawHost, keys, identities }));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [hostMap, sessions, keys, identities]);
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!sessionHost) return;
|
||||
@@ -644,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]);
|
||||
|
||||
@@ -731,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);
|
||||
@@ -756,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.
|
||||
@@ -801,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(() => {
|
||||
@@ -831,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
|
||||
@@ -869,6 +871,32 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handleAddSelectionToAI = useCallback((sourceSessionId: string, selection: string) => {
|
||||
const text = selection.trim();
|
||||
if (!text) return;
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
if (!tabId) return;
|
||||
|
||||
const ws = activeWorkspaceRef.current;
|
||||
if (ws && ws.focusedSessionId !== sourceSessionId) {
|
||||
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
|
||||
}
|
||||
|
||||
setPendingTerminalSelectionForAI({
|
||||
requestId: crypto.randomUUID(),
|
||||
tabId,
|
||||
text,
|
||||
});
|
||||
handleSwitchSidePanelTab('ai');
|
||||
}, [handleSwitchSidePanelTab]);
|
||||
|
||||
const handlePendingTerminalSelectionConsumed = useCallback((requestId: string) => {
|
||||
setPendingTerminalSelectionForAI((current) => (
|
||||
current?.requestId === requestId ? null : current
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Toggle the AI chat side panel from the top-bar button: open it (or switch
|
||||
// to it from another sub-panel), and close the side panel when AI is already
|
||||
// the open sub-panel. Unlike handleOpenAI (the rail switch), a second click
|
||||
@@ -891,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) {
|
||||
@@ -906,126 +934,232 @@ 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' }
|
||||
: undefined,
|
||||
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat],
|
||||
);
|
||||
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) {
|
||||
terminalBackend.writeToSession(sid, payload);
|
||||
const executor = snippetExecutorsRef.current.get(sid);
|
||||
if (executor) {
|
||||
executor(text, false, { broadcast: false });
|
||||
} else {
|
||||
terminalBackend.writeToSession(sid, payload);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
if (targetId) {
|
||||
terminalBackend.writeToSession(targetId, payload);
|
||||
const executor = snippetExecutorsRef.current.get(targetId);
|
||||
if (executor) {
|
||||
executor(text, false);
|
||||
} else {
|
||||
terminalBackend.writeToSession(targetId, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [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, handleBroadcastInput, handleCloseSession, handleCloseSidePanel, handleCommandExecuted, handleComposeSend, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, handleOpenAI, handleOpenScripts, handleOpenSftp, handleOpenTheme, handleOsDetected, 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, previewedOrVisibleThemeId, refocusActiveTerminalSession, refocusTerminalSession, renderFocusModeSidebar, resizing, resolveAIExecutorContext, resolvedPreviewTheme, ScriptsSidePanel, sessionChainHostsMap, sessionHostsMap, sessionLogConfig, 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);
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
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;
|
||||
@@ -41,6 +50,7 @@ interface TopTabsProps {
|
||||
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
|
||||
onRenameSession: (sessionId: string) => void;
|
||||
onCopySession: (sessionId: string) => void;
|
||||
onCopySessionToNewWindow: (sessionId: string) => void;
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
@@ -48,6 +58,8 @@ interface TopTabsProps {
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
windowOpacity: number;
|
||||
setWindowOpacity: (opacity: number) => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
@@ -73,6 +85,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onCloseSession,
|
||||
onRenameSession,
|
||||
onCopySession,
|
||||
onCopySessionToNewWindow,
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
@@ -80,6 +93,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
@@ -93,6 +108,14 @@ 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 hostTreeToggleSlotRef = useRef<HTMLDivElement>(null);
|
||||
const [hostTreeTabGutter, setHostTreeTabGutter] = useState(0);
|
||||
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
@@ -197,6 +220,55 @@ 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(Math.max(0, hostTreeLayoutWidth - toggleRight));
|
||||
}, [hostTreeLayoutWidth, showHostTreeToggle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHostTreeTabGutter();
|
||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
if (!root) return;
|
||||
const ro = new ResizeObserver(() => updateHostTreeTabGutter());
|
||||
ro.observe(root);
|
||||
if (tabsContainerRef.current) ro.observe(tabsContainerRef.current);
|
||||
if (hostTreeToggleSlotRef.current) ro.observe(hostTreeToggleSlotRef.current);
|
||||
window.addEventListener('resize', updateHostTreeTabGutter);
|
||||
return () => {
|
||||
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);
|
||||
@@ -256,6 +328,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) {
|
||||
@@ -376,6 +456,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
host={host}
|
||||
suffix={suffix}
|
||||
onRequestCloseEditorTab={onRequestCloseEditorTab}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -407,8 +488,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onCloseSession={onCloseSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onCopySession={onCopySession}
|
||||
onCopySessionToNewWindow={onCopySessionToNewWindow}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(session.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -442,6 +525,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onCloseWorkspace={onCloseWorkspace}
|
||||
renderBulkCloseItems={renderBulkCloseItems}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(workspace.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -455,6 +539,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
logView={logView}
|
||||
onCloseLogView={onCloseLogView}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(logView.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -501,6 +586,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
label="Vaults"
|
||||
icon={<FolderLock size={14} />}
|
||||
className="rounded"
|
||||
compact={showHostTreeToggle}
|
||||
/>
|
||||
{showSftpTab && (
|
||||
<RootTopTab
|
||||
@@ -508,6 +594,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
label="SFTP"
|
||||
icon={<Folder size={14} />}
|
||||
className="rounded-t-md"
|
||||
compact={showHostTreeToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -525,49 +612,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 */}
|
||||
@@ -588,14 +721,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'))}
|
||||
>
|
||||
@@ -604,13 +740,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}
|
||||
@@ -620,15 +767,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}
|
||||
>
|
||||
@@ -637,11 +781,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>
|
||||
);
|
||||
@@ -659,7 +802,11 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.logViews === next.logViews &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.isMacClient === next.isMacClient &&
|
||||
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 &&
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
Snippet,
|
||||
} from "../types";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { connectHostsStaggered } from "./connectHostsStaggered";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import GroupDetailsPanel from "./GroupDetailsPanel";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
@@ -86,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 {
|
||||
@@ -615,7 +619,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// We call onConnect directly (not handleHostConnect) so multi-protocol hosts
|
||||
// connect with their configured protocol instead of opening a per-host dialog.
|
||||
const targets = hosts.filter(h => selectedHostIds.has(h.id));
|
||||
targets.forEach(host => onConnect(host));
|
||||
// Stagger the connects across frames so mounting N terminals (each creating
|
||||
// a WebGL context) doesn't block one frame and freeze the UI. The first host
|
||||
// still connects synchronously so its tab appears immediately.
|
||||
connectHostsStaggered(targets, onConnect);
|
||||
clearHostSelection();
|
||||
toast.success(t("vault.hosts.connectMultiple.success", { count: targets.length }));
|
||||
}, [selectedHostIds, hosts, onConnect, clearHostSelection, t]);
|
||||
@@ -935,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 &&
|
||||
@@ -974,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;
|
||||
@@ -6,7 +6,7 @@
|
||||
* and a bottom toolbar with muted controls + subtle send button.
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, SquareTerminal, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -125,6 +125,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
providerSwitcher,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
|
||||
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
|
||||
@@ -430,18 +431,32 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
className={[
|
||||
"inline-flex items-center gap-1 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group",
|
||||
file.terminalSelection ? "h-6 max-w-[260px]" : "h-6",
|
||||
].join(" ")}
|
||||
>
|
||||
{file.mediaType.startsWith('image/') ? (
|
||||
{file.terminalSelection ? (
|
||||
<SquareTerminal size={12} className="text-muted-foreground/70 shrink-0" />
|
||||
) : file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
{file.terminalSelection ? (
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate max-w-[210px] text-foreground/82">
|
||||
{t('ai.chat.terminalSelectionAttachment')}
|
||||
{file.lineCount ? ` · ${t('ai.chat.terminalSelectionLines').replace('{count}', String(file.lineCount))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile?.(file.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer shrink-0"
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
@@ -941,7 +956,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={!value.trim() || disabled}
|
||||
disabled={(!value.trim() && !hasTerminalSelectionAttachment) || disabled}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-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}>
|
||||
@@ -210,7 +237,15 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{isUser && (message.attachments ?? message.images)?.length && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{(message.attachments ?? message.images)!.map((att, i) => (
|
||||
att.mediaType.startsWith('image/') ? (
|
||||
att.terminalSelection ? (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<SquareTerminal size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">{att.filename || 'terminal selection'}</span>
|
||||
</div>
|
||||
) : att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
src={`data:${att.mediaType};base64,${att.base64Data}`}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import { createTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment.ts";
|
||||
import {
|
||||
buildExternalAgentHistoryMessages,
|
||||
buildExternalAgentHistoryMessagesForBridge,
|
||||
@@ -74,6 +75,24 @@ test("buildExternalAgentHistoryMessagesForBridge keeps fallback history availabl
|
||||
);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages expands terminal selection attachments", () => {
|
||||
const terminalSelection = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
assert.ok(terminalSelection);
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "", {
|
||||
attachments: [terminalSelection],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /\[Terminal selection:/);
|
||||
assert.match(result[0].content, /docker ps -a/);
|
||||
assert.match(result[0].content, /permission denied/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import { buildPromptWithTerminalSelectionAttachments } from "../../application/state/terminalSelectionAttachment.ts";
|
||||
|
||||
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
|
||||
@@ -59,6 +60,14 @@ function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function getUserHistoryContent(message: ChatMessage): string {
|
||||
if (message.role !== "user") return message.content || "";
|
||||
return buildPromptWithTerminalSelectionAttachments(
|
||||
message.content || "",
|
||||
message.attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
@@ -142,9 +151,10 @@ function summarizeMessage(
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const content = getUserHistoryContent(message);
|
||||
if (content && isImportantText(content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
@@ -159,9 +169,11 @@ function summarizeMessage(
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
if (message.role !== "user") return null;
|
||||
const content = getUserHistoryContent(message);
|
||||
if (!content) return null;
|
||||
if (isTrivialUserMessage(content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
@@ -217,8 +229,9 @@ function toRawHistoryMessage(
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
const content = getUserHistoryContent(message);
|
||||
return content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -290,7 +303,7 @@ function buildCompactContext(
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
priority: getDurableUserPriority(getUserHistoryContent(message)),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
|
||||
@@ -42,6 +42,10 @@ import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAg
|
||||
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../../../application/state/terminalSelectionAttachment';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
@@ -78,6 +82,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 & {
|
||||
@@ -204,6 +213,7 @@ export interface SendToCattyContext {
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
titleText?: string;
|
||||
selectedUserSkillSlugs?: string[];
|
||||
}
|
||||
|
||||
@@ -800,10 +810,16 @@ export function useAIChatStreaming({
|
||||
if (m.role === 'user') {
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
if (messageAttachments?.length) {
|
||||
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: m.content });
|
||||
for (const att of messageAttachments) {
|
||||
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 {
|
||||
@@ -812,7 +828,7 @@ export function useAIChatStreaming({
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
sdkMessages.push({ role: 'user', content: modelText });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
const activeContinuation = isProviderContinuationForSource(
|
||||
@@ -1008,7 +1024,7 @@ export function useAIChatStreaming({
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
context.autoTitleSession(sessionId, trimmed);
|
||||
context.autoTitleSession(sessionId, context.titleText ?? trimmed);
|
||||
}
|
||||
}, [
|
||||
processCattyStream, reportStreamError, setStreamingForScope,
|
||||
|
||||
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;
|
||||
}
|
||||
81
components/connectHostsStaggered.test.ts
Normal file
81
components/connectHostsStaggered.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { connectHostsStaggered } from "./connectHostsStaggered";
|
||||
|
||||
test("connects the first target synchronously and defers the rest", () => {
|
||||
const connected: string[] = [];
|
||||
const scheduled: Array<{ delay: number; run: () => void }> = [];
|
||||
|
||||
connectHostsStaggered(["a", "b", "c"], (h) => connected.push(h), {
|
||||
stepMs: 100,
|
||||
schedule: (cb, delay) => {
|
||||
scheduled.push({ delay, run: cb });
|
||||
},
|
||||
});
|
||||
|
||||
// First terminal mounts immediately so a tab appears without waiting.
|
||||
assert.deepEqual(connected, ["a"]);
|
||||
// The remaining two are deferred, not run on the same frame.
|
||||
assert.equal(scheduled.length, 2);
|
||||
});
|
||||
|
||||
test("running the scheduled callbacks connects the rest in order", () => {
|
||||
const connected: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
|
||||
connectHostsStaggered(["a", "b", "c"], (h) => connected.push(h), {
|
||||
schedule: (cb) => {
|
||||
scheduled.push(cb);
|
||||
},
|
||||
});
|
||||
|
||||
scheduled.forEach((run) => run());
|
||||
|
||||
assert.deepEqual(connected, ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("spreads deferred connects across increasing delays", () => {
|
||||
const delays: number[] = [];
|
||||
|
||||
connectHostsStaggered(["a", "b", "c", "d"], () => {}, {
|
||||
stepMs: 80,
|
||||
schedule: (_cb, delay) => {
|
||||
delays.push(delay);
|
||||
},
|
||||
});
|
||||
|
||||
// index 1..n each scheduled one step further out so no two heavy mounts
|
||||
// land on the same frame.
|
||||
assert.deepEqual(delays, [80, 160, 240]);
|
||||
});
|
||||
|
||||
test("single target connects synchronously with no scheduling", () => {
|
||||
const connected: string[] = [];
|
||||
let scheduleCalls = 0;
|
||||
|
||||
connectHostsStaggered(["only"], (h) => connected.push(h), {
|
||||
schedule: () => {
|
||||
scheduleCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(connected, ["only"]);
|
||||
assert.equal(scheduleCalls, 0);
|
||||
});
|
||||
|
||||
test("empty target list does nothing", () => {
|
||||
let connectCalls = 0;
|
||||
let scheduleCalls = 0;
|
||||
|
||||
connectHostsStaggered([], () => {
|
||||
connectCalls += 1;
|
||||
}, {
|
||||
schedule: () => {
|
||||
scheduleCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(connectCalls, 0);
|
||||
assert.equal(scheduleCalls, 0);
|
||||
});
|
||||
42
components/connectHostsStaggered.ts
Normal file
42
components/connectHostsStaggered.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Schedule a batch of host connections across separate frames instead of all
|
||||
* at once.
|
||||
*
|
||||
* Connecting many hosts in a single synchronous pass mounts every terminal in
|
||||
* one React commit, so each terminal's `createXTermRuntime()` (which spins up a
|
||||
* live WebGL context) runs back-to-back on the main thread and freezes the UI
|
||||
* until the whole batch finishes. Staggering lets the first tab paint
|
||||
* immediately and gives the renderer room to breathe between mounts.
|
||||
*
|
||||
* The first target connects synchronously so a tab shows up without delay; the
|
||||
* rest are deferred one `stepMs` apart. `schedule` is injectable for testing.
|
||||
*/
|
||||
export type StaggerScheduler = (callback: () => void, delayMs: number) => void;
|
||||
|
||||
export interface ConnectHostsStaggeredOptions {
|
||||
/** Gap between successive deferred connections, in ms. */
|
||||
stepMs?: number;
|
||||
/** Defers a callback; defaults to setTimeout. */
|
||||
schedule?: StaggerScheduler;
|
||||
}
|
||||
|
||||
const defaultSchedule: StaggerScheduler = (callback, delayMs) => {
|
||||
setTimeout(callback, delayMs);
|
||||
};
|
||||
|
||||
export function connectHostsStaggered<T>(
|
||||
targets: T[],
|
||||
onConnect: (target: T) => void,
|
||||
options: ConnectHostsStaggeredOptions = {},
|
||||
): void {
|
||||
const stepMs = options.stepMs ?? 90;
|
||||
const schedule = options.schedule ?? defaultSchedule;
|
||||
|
||||
targets.forEach((target, index) => {
|
||||
if (index === 0) {
|
||||
onConnect(target);
|
||||
} else {
|
||||
schedule(() => onConnect(target), index * stepMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -83,6 +82,8 @@ interface SettingsSystemTabProps {
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
sessionLogsTimestampsEnabled: boolean;
|
||||
setSessionLogsTimestampsEnabled: (enabled: boolean) => void;
|
||||
sshDebugLogsEnabled: boolean;
|
||||
setSshDebugLogsEnabled: (enabled: boolean) => void;
|
||||
toggleWindowHotkey: string;
|
||||
@@ -109,6 +110,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
setSessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
setSshDebugLogsEnabled,
|
||||
toggleWindowHotkey,
|
||||
@@ -388,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">
|
||||
@@ -521,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>
|
||||
@@ -541,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">
|
||||
@@ -593,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>
|
||||
@@ -740,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">
|
||||
@@ -828,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")}
|
||||
@@ -907,21 +864,25 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.sessionLogs.timestamps")}
|
||||
description={t("settings.sessionLogs.timestampsDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={sessionLogsTimestampsEnabled}
|
||||
onChange={setSessionLogsTimestampsEnabled}
|
||||
disabled={!sessionLogsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
</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")}
|
||||
@@ -934,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>
|
||||
@@ -974,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')}
|
||||
@@ -1053,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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -873,10 +873,19 @@ export default function SettingsTerminalTab(props: {
|
||||
className="w-32"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.rendering.lineTimestamps")}
|
||||
description={t("settings.terminal.rendering.lineTimestamps.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.showLineTimestamps}
|
||||
onChange={(v) => updateTerminalSetting("showLineTimestamps", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</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")}
|
||||
@@ -888,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -523,6 +527,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
showHiddenFiles={pane.showHiddenFiles}
|
||||
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
|
||||
onGoToTerminalCwd={onGoToTerminalCwd}
|
||||
followTerminalCwd={followTerminalCwd}
|
||||
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
|
||||
viewMode={viewMode}
|
||||
onSetViewMode={handleSetViewMode}
|
||||
onListDrives={callbacks.onListDrives}
|
||||
@@ -685,6 +691,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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -34,6 +33,8 @@ interface TerminalAutocompleteProps {
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
sudoHintRef: HandlerRef<(active: boolean) => boolean>;
|
||||
sudoHintText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,6 @@ export function TerminalAutocomplete({
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
visible,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
@@ -67,7 +67,12 @@ export function TerminalAutocomplete({
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
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,
|
||||
@@ -88,6 +93,13 @@ export function TerminalAutocomplete({
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
sudoHintRef.current = (active: boolean): boolean => {
|
||||
if (!active) {
|
||||
autocomplete.hideSudoHint();
|
||||
return false;
|
||||
}
|
||||
return autocomplete.showSudoHint(sudoHintText);
|
||||
};
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Terminal as TerminalIcon,
|
||||
@@ -41,6 +42,7 @@ export interface TerminalContextMenuProps {
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
}
|
||||
|
||||
export const shouldShowReconnectAction = ({
|
||||
@@ -77,6 +79,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onReconnect,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
@@ -157,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')}
|
||||
@@ -168,6 +171,12 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onAddSelectionToAI && (
|
||||
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
{t('terminal.menu.addSelectionToAI')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onPasteSelection && (
|
||||
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user