Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a3560a19f | ||
|
|
3b525300e0 | ||
|
|
08ff49d3f5 | ||
|
|
f5c4271a07 | ||
|
|
74d41b43b6 | ||
|
|
3408bba303 | ||
|
|
5e00e998a8 | ||
|
|
3847f0cda0 | ||
|
|
1ebcd017bd | ||
|
|
9013a7e312 | ||
|
|
afefbd953f | ||
|
|
535b141b23 | ||
|
|
b21e44b65f | ||
|
|
b42be379e3 | ||
|
|
b2f0a3bea3 | ||
|
|
df3745d185 | ||
|
|
f85bb3f9b2 | ||
|
|
566f3e3c32 | ||
|
|
58eb91fb23 | ||
|
|
36267717ac | ||
|
|
5e323f1f8f | ||
|
|
c0efc9d5c1 | ||
|
|
61188ab8e2 | ||
|
|
ae209d37c1 | ||
|
|
a5b0efba75 | ||
|
|
5adb64e40e | ||
|
|
41fea1028d | ||
|
|
5a90a4331b | ||
|
|
881f3b1a34 | ||
|
|
8be5865b76 | ||
|
|
685d1cb41a | ||
|
|
14fe1e3ecb | ||
|
|
636f4d7037 | ||
|
|
c92ad2f601 | ||
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
602ca92476 | ||
|
|
f413035295 | ||
|
|
bfd3fb4dad | ||
|
|
733e19a6f6 | ||
|
|
85b552e1a6 | ||
|
|
068730c53c | ||
|
|
c9d84c7ce3 | ||
|
|
d558aea7de | ||
|
|
e211eec693 | ||
|
|
6b1277d3e1 | ||
|
|
35bf38be70 | ||
|
|
555c00406e | ||
|
|
e67012654a | ||
|
|
ecdb1d17cd | ||
|
|
a5578b5e60 | ||
|
|
fb4641878f | ||
|
|
7d6f30f51f | ||
|
|
9869b645b1 | ||
|
|
037b85bd66 | ||
|
|
ba784b8b35 | ||
|
|
eae760db3f | ||
|
|
4b5993cad6 | ||
|
|
6af62aa093 | ||
|
|
61e8de4270 | ||
|
|
27dce4e427 | ||
|
|
8b53fb1c7b | ||
|
|
6c1661dc3c | ||
|
|
3662b45121 | ||
|
|
437253179e | ||
|
|
d85f4edbbb | ||
|
|
96c9ccaaa0 | ||
|
|
3203ed7a19 | ||
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 | ||
|
|
846d8246a3 | ||
|
|
26a04b22d3 | ||
|
|
f5f55ffc2e | ||
|
|
0792ce1415 | ||
|
|
eca23a2691 | ||
|
|
aa1781577b | ||
|
|
409d293faa | ||
|
|
39fea86f13 | ||
|
|
ce5d1d0e5a | ||
|
|
7ac29366ae | ||
|
|
4860581525 | ||
|
|
d9156349e1 | ||
|
|
983b0b2f1d | ||
|
|
a552c14cbd | ||
|
|
3f5787ceb1 | ||
|
|
e4ec2363d0 | ||
|
|
84b71910ee | ||
|
|
371217832b | ||
|
|
afb514b472 | ||
|
|
e14dc22bba | ||
|
|
6b7c12c23c | ||
|
|
222b3869dd | ||
|
|
56af2d3840 | ||
|
|
1695470089 | ||
|
|
d4b5f799cb |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -34,7 +34,7 @@ body:
|
||||
attributes:
|
||||
label: How did you install Netcatty?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb)
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
|
||||
- Homebrew
|
||||
- Built from source (npm run dev / pack)
|
||||
- Other
|
||||
|
||||
11
.github/scripts/generate-release-note.js
vendored
@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
// - pacman: x64 -> x64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -70,6 +71,10 @@ const files = {
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
},
|
||||
pacman: {
|
||||
x64: `Netcatty-${version}-linux-x64.pacman`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.pacman`
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -88,7 +93,9 @@ const badges = {
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`
|
||||
rpm_arm64: `[](${baseUrl}/${files.linux.rpm.arm64})`,
|
||||
pacman_x64: `[](${baseUrl}/${files.linux.pacman.x64})`,
|
||||
pacman_arm64: `[](${baseUrl}/${files.linux.pacman.arm64})`
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +106,7 @@ const content = `
|
||||
| :--- | :--- |
|
||||
| **Windows** | ${badges.win.setup_x64} |
|
||||
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
|
||||
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
|
||||
`;
|
||||
|
||||
fs.writeFileSync('release_notes.md', content);
|
||||
|
||||
8
.github/workflows/build.yml
vendored
@@ -348,6 +348,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
@@ -410,6 +411,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Install pacman packaging dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -457,6 +461,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -510,6 +515,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
|
||||
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
@@ -568,6 +574,7 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.pacman
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
@@ -673,6 +680,7 @@ jobs:
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.pacman
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
|
||||
10
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -41,6 +42,15 @@ coverage
|
||||
# Codex
|
||||
/.codex/
|
||||
|
||||
# Qoder
|
||||
.qoder
|
||||
|
||||
# Workbuddy
|
||||
.workbuddy
|
||||
|
||||
# Codebuddy
|
||||
.codebuddy
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
/docs/superpowers/
|
||||
|
||||
|
||||
37
App.tsx
@@ -138,7 +138,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
reapplyCurrentTheme,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
@@ -229,6 +229,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
@@ -244,6 +246,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
@@ -267,12 +270,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive Mode — derive UI chrome colors from the active terminal's theme
|
||||
// Active tab lookup maps
|
||||
// ---------------------------------------------------------------------------
|
||||
const customThemes = useCustomThemes();
|
||||
const editorTabs = useEditorTabs();
|
||||
|
||||
// Resolve the effective TerminalTheme for the currently focused terminal tab
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
@@ -291,8 +293,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
|
||||
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
// activeTabId-derived chrome (window title, sftp guard) is owned by
|
||||
// <AppActiveTabChrome/> so switching tabs does not re-render App.
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -697,12 +699,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
const editorTabTopIds = useMemo(
|
||||
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
|
||||
[editorTabs],
|
||||
);
|
||||
|
||||
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
|
||||
const orderedTabsWithEditors = useMemo(
|
||||
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
|
||||
[orderedTabs, editorTabs],
|
||||
() => getOrderedWorkTabs(editorTabTopIds),
|
||||
[editorTabTopIds, getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderWorkTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
) => {
|
||||
reorderTabs(draggedId, targetId, position, editorTabTopIds);
|
||||
}, [editorTabTopIds, reorderTabs]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
@@ -712,7 +727,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
|
||||
|
||||
const handleWindowCommandCloseRequest = useCallback(async () => {
|
||||
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
|
||||
@@ -959,20 +974,20 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
<AppActiveTabChrome
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setActiveTabId={setActiveTabId}
|
||||
applyAppTheme={applyAppTheme}
|
||||
hostById={hostById}
|
||||
sessionById={sessionById}
|
||||
workspaceById={workspaceById}
|
||||
themeById={themeById}
|
||||
workspaceById={workspaceById}
|
||||
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 }} />
|
||||
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
|
||||
import { matchesKeyBinding } from '../domain/models.ts';
|
||||
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
|
||||
|
||||
class FakeInputHTMLElement {
|
||||
tagName = 'INPUT';
|
||||
isContentEditable = false;
|
||||
|
||||
closest(): FakeInputHTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeHTMLElement {
|
||||
tagName = 'TEXTAREA';
|
||||
isContentEditable = false;
|
||||
@@ -68,3 +77,95 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('global hotkey handler routes quick switch through focused search inputs', () => {
|
||||
const target = new FakeInputHTMLElement();
|
||||
const handledActions: string[] = [];
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
handleGlobalHotkeyKeyDownImpl(
|
||||
() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => {
|
||||
handledActions.push(action);
|
||||
},
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}),
|
||||
event,
|
||||
);
|
||||
|
||||
assert.deepEqual(handledActions, ['quickSwitch']);
|
||||
});
|
||||
|
||||
test('quick switch hotkey toggles the quick switcher open state', () => {
|
||||
let isQuickSwitcherOpen = false;
|
||||
const setIsQuickSwitcherOpen = (next: boolean) => {
|
||||
isQuickSwitcherOpen = next;
|
||||
};
|
||||
const noop = () => {};
|
||||
const baseCtx = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
const event = {
|
||||
key: 'j',
|
||||
code: 'KeyJ',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
} as KeyboardEvent;
|
||||
|
||||
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, true);
|
||||
|
||||
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
|
||||
assert.equal(isQuickSwitcherOpen, false);
|
||||
});
|
||||
|
||||
@@ -5,14 +5,10 @@ import {
|
||||
isEditorTabId,
|
||||
useActiveTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import { setImmersiveActive } from '../state/immersiveStore';
|
||||
import { useImmersiveMode } from '../state/useImmersiveMode';
|
||||
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
|
||||
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveActiveChromeTheme } from './activeChromeTheme';
|
||||
import type {
|
||||
Host,
|
||||
TerminalSession,
|
||||
@@ -25,15 +21,15 @@ import type { EditorTab } from '../state/editorTabStore';
|
||||
interface AppActiveTabChromeProps {
|
||||
showSftpTab: boolean;
|
||||
setActiveTabId: (id: string) => void;
|
||||
applyAppTheme: () => void;
|
||||
hostById: Map<string, Host>;
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
followAppTerminalTheme: boolean;
|
||||
accentMode: 'theme' | 'custom';
|
||||
customAccent: string;
|
||||
reapplyCurrentTheme: () => void;
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
t: (key: string) => string;
|
||||
@@ -41,27 +37,24 @@ interface AppActiveTabChromeProps {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* work derived from it: window title and the SFTP-tab guard.
|
||||
* Extracted out of <App> so that switching top tabs only
|
||||
* re-renders this null-rendering component (and the self-subscribing leaves)
|
||||
* instead of forcing the entire App tree (which holds all vault/session/
|
||||
* settings state and rebuilds the giant AppView ctx) to re-render.
|
||||
*
|
||||
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
|
||||
* and TopTabs can read it without re-rendering App.
|
||||
*/
|
||||
export function AppActiveTabChrome({
|
||||
showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
currentTerminalTheme,
|
||||
followAppTerminalTheme,
|
||||
accentMode,
|
||||
customAccent,
|
||||
reapplyCurrentTheme,
|
||||
editorTabs,
|
||||
logViews,
|
||||
t,
|
||||
@@ -74,55 +67,43 @@ export function AppActiveTabChrome({
|
||||
}
|
||||
}, [showSftpTab, activeTabId, setActiveTabId]);
|
||||
|
||||
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
|
||||
const chromeThemeDeps = useMemo(() => ({
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}), [
|
||||
accentMode,
|
||||
applyAppTheme,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
]);
|
||||
|
||||
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);
|
||||
};
|
||||
updateActiveChromeThemeDeps(chromeThemeDeps);
|
||||
|
||||
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({
|
||||
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
|
||||
...chromeThemeDeps,
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme: reapplyCurrentTheme,
|
||||
});
|
||||
}), [chromeThemeDeps, activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
setImmersiveActive(activeTerminalTheme !== null);
|
||||
}, [activeTerminalTheme]);
|
||||
useActiveChromeTheme({
|
||||
activeTheme: activeChromeTheme,
|
||||
applyAppTheme,
|
||||
});
|
||||
|
||||
const editorTabFileNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
import type React from 'react';
|
||||
import type { Host, HostProtocol } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import { getEffectiveHostDistro } from '../../domain/host';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
|
||||
const getLogHostVisualSnapshot = (host: Host) => ({
|
||||
hostOs: host.os,
|
||||
hostDistro: getEffectiveHostDistro(host) || undefined,
|
||||
});
|
||||
|
||||
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
|
||||
{
|
||||
@@ -65,6 +72,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -83,6 +91,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -123,7 +132,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
|
||||
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
|
||||
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
|
||||
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
|
||||
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
|
||||
@@ -436,13 +449,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
const allTabs = settings.showSftpTab
|
||||
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
|
||||
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
|
||||
const numberShortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= allTabs.length) {
|
||||
setActiveTabId(allTabs[num - 1]);
|
||||
if (num <= numberShortcutTabs.length) {
|
||||
setActiveTabId(numberShortcutTabs[num - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -545,6 +564,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
}
|
||||
break;
|
||||
case 'quickSwitch':
|
||||
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
|
||||
break;
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
@@ -708,6 +729,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
@@ -726,6 +748,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
|
||||
44
application/app/AppHostTreeLayer.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getAppHostTreeLayerStyle,
|
||||
} = await import('./AppHostTreeLayer');
|
||||
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('shared host tree layer is visible above work tabs', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(true), {
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree layer is hidden behind root pages', () => {
|
||||
assert.deepEqual(getAppHostTreeLayerStyle(false), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('shared host tree does not force open when entering a work tab surface', () => {
|
||||
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
89
application/app/AppHostTreeLayer.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
editorTabs: readonly EditorTab[];
|
||||
logViews: readonly LogView[];
|
||||
orderedTabs: readonly string[];
|
||||
resolvedPreviewTheme: TerminalTheme;
|
||||
onConnect: (host: Host) => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
}
|
||||
|
||||
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
|
||||
return {
|
||||
visibility: surfaceVisible ? 'visible' : 'hidden',
|
||||
pointerEvents: surfaceVisible ? 'auto' : 'none',
|
||||
zIndex: surfaceVisible ? 30 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
|
||||
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
|
||||
const surfaceVisible = isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
|
||||
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, editorTabs, sessions, workspaces]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 flex min-h-0"
|
||||
data-section="app-host-tree-layer"
|
||||
style={getAppHostTreeLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<TerminalHostTreeSidebar
|
||||
enabled={enabled}
|
||||
surfaceVisible={surfaceVisible}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
resolvedPreviewTheme={resolvedPreviewTheme}
|
||||
activeHostId={activeHostId}
|
||||
onConnect={onConnect}
|
||||
onCreateLocalTerminal={onCreateLocalTerminal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
application/app/AppMounts.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
|
||||
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('visible log view leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden log view remains hidden while preserving host sidebar offset', () => {
|
||||
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
left: 220,
|
||||
});
|
||||
});
|
||||
|
||||
test('terminal layer renders only after terminal content is visible or mounted', () => {
|
||||
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
|
||||
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
|
||||
});
|
||||
|
||||
test('active tab chrome keeps removed theme side effects unmounted', () => {
|
||||
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
|
||||
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
|
||||
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { Suspense, lazy, useEffect, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
|
||||
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ConnectionLog, TerminalTheme } from '../../types';
|
||||
import type { LogView as LogViewType } from '../state/logViewState';
|
||||
@@ -29,14 +31,24 @@ interface LogViewWrapperProps {
|
||||
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
|
||||
}
|
||||
|
||||
export function getLogViewWrapperStyle(
|
||||
isVisible: boolean,
|
||||
hostTreeLayoutWidth: number,
|
||||
): React.CSSProperties {
|
||||
const baseStyle = {
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
return isVisible
|
||||
? baseStyle
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
|
||||
}
|
||||
|
||||
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVisible = activeTabId === logView.id;
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
// Use same pattern as VaultViewContainer for visibility
|
||||
const containerStyle: React.CSSProperties = isVisible
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
|
||||
|
||||
return (
|
||||
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
|
||||
@@ -67,6 +79,13 @@ const LazyTerminalLayer = lazy(() =>
|
||||
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
|
||||
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
|
||||
|
||||
export function shouldRenderTerminalLayerMount(
|
||||
isVisible: boolean,
|
||||
shouldMount: boolean,
|
||||
): boolean {
|
||||
return isVisible || shouldMount;
|
||||
}
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
@@ -85,7 +104,14 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
};
|
||||
|
||||
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
|
||||
const activeTabId = useActiveTabId();
|
||||
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
|
||||
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
|
||||
const isVisible = isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}) || !!props.draggingSessionId;
|
||||
const [shouldMount, setShouldMount] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,7 +133,7 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [shouldMount]);
|
||||
|
||||
const shouldRender = shouldMount || isVisible;
|
||||
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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';
|
||||
@@ -19,7 +18,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
@@ -43,10 +42,10 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
|
||||
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId,
|
||||
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
|
||||
@@ -56,12 +55,6 @@ 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>
|
||||
@@ -113,10 +106,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={resolvedTheme}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
hosts={hosts}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
@@ -139,17 +131,32 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={isImmersive}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
onEndSessionDrag={handleEndSessionDrag}
|
||||
onReorderTabs={reorderTabs}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
editorTabs={editorTabs}
|
||||
onRequestCloseEditorTab={handleRequestCloseEditorTab}
|
||||
hostById={hostById}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<AppHostTreeLayer
|
||||
enabled={settings.showHostTreeSidebar}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
groupConfigs={groupConfigs}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
@@ -248,6 +255,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onUpdateTerminalThemeId={setTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={setTerminalFontSize}
|
||||
onUpdateSessionFontSize={updateSessionFontSize}
|
||||
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
|
||||
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
|
||||
onCloseSession={closeSession}
|
||||
onUpdateSessionStatus={handleSessionStatusChange}
|
||||
@@ -257,6 +266,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
|
||||
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
|
||||
}}
|
||||
shellHistory={shellHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
@@ -274,6 +284,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
updateHosts={updateHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
@@ -289,6 +301,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
|
||||
sshDebugLogsEnabled={sshDebugLogsEnabled}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
|
||||
toggleSidePanelRef={toggleSidePanelRef}
|
||||
/>
|
||||
|
||||
106
application/app/activeChromeTheme.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { toEditorTabId } from "../state/activeTabStore.ts";
|
||||
import type { EditorTab } from "../state/editorTabStore.ts";
|
||||
import type { LogView } from "../state/logViewState.ts";
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
colors: {
|
||||
background: type === "dark" ? "#111111" : "#eeeeee",
|
||||
foreground: type === "dark" ? "#eeeeee" : "#111111",
|
||||
cursor: "#22aaff",
|
||||
},
|
||||
});
|
||||
|
||||
const currentTheme = theme("current");
|
||||
const hostTheme = theme("host-theme");
|
||||
const logTheme = theme("log-theme", "light");
|
||||
|
||||
const baseInput = {
|
||||
accentMode: "theme" as const,
|
||||
currentTerminalTheme: currentTheme,
|
||||
customAccent: "221.2 83.2% 53.3%",
|
||||
editorTabs: [],
|
||||
followAppTerminalTheme: false,
|
||||
hostById: new Map<string, Host>(),
|
||||
logViews: [],
|
||||
sessionById: new Map<string, TerminalSession>(),
|
||||
themeById: new Map([
|
||||
[currentTheme.id, currentTheme],
|
||||
[hostTheme.id, hostTheme],
|
||||
[logTheme.id, logTheme],
|
||||
]),
|
||||
workspaceById: new Map<string, Workspace>(),
|
||||
};
|
||||
|
||||
test("editor tabs use the theme from their owning host", () => {
|
||||
const editorTab = {
|
||||
id: "editor-1",
|
||||
hostId: "host-1",
|
||||
sessionId: "sftp-1",
|
||||
};
|
||||
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: toEditorTabId(editorTab.id),
|
||||
editorTabs: [editorTab as unknown as EditorTab],
|
||||
hostById: new Map([
|
||||
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
|
||||
]),
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, hostTheme.id);
|
||||
});
|
||||
|
||||
test("log tabs use the saved log theme when available", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "log-1",
|
||||
logViews: [{
|
||||
id: "log-1",
|
||||
connectionLogId: "1",
|
||||
log: { id: "1", themeId: logTheme.id },
|
||||
} as unknown as LogView],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.id, logTheme.id);
|
||||
});
|
||||
|
||||
test("root pages use the normal application theme", () => {
|
||||
const resolved = resolveActiveChromeTheme({
|
||||
...baseInput,
|
||||
activeTabId: "vault",
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test("chrome theme sync waits until a newly opened session is present in deps", () => {
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map(),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isActiveChromeThemeResolvable({
|
||||
activeTabId: "session-new",
|
||||
editorTabs: [],
|
||||
logViews: [],
|
||||
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
|
||||
workspaceById: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
104
application/app/activeChromeTheme.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
|
||||
|
||||
export type ResolveActiveChromeThemeInput = {
|
||||
accentMode: "theme" | "custom";
|
||||
activeTabId: string;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
export function isActiveChromeThemeResolvable({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
logViews,
|
||||
sessionById,
|
||||
workspaceById,
|
||||
}: Pick<
|
||||
ResolveActiveChromeThemeInput,
|
||||
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
|
||||
>): boolean {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return true;
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
|
||||
}
|
||||
if (logViews.some((item) => item.id === activeTabId)) return true;
|
||||
if (workspaceById.has(activeTabId)) return true;
|
||||
if (sessionById.has(activeTabId)) return true;
|
||||
return false;
|
||||
}
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
|
||||
import { collectSessionIds } from "../../domain/workspace";
|
||||
import type { EditorTab } from "../state/editorTabStore";
|
||||
import type { LogView } from "../state/logViewState";
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
|
||||
|
||||
export function resolveActiveChromeTheme({
|
||||
accentMode,
|
||||
activeTabId,
|
||||
currentTerminalTheme,
|
||||
customAccent,
|
||||
editorTabs,
|
||||
followAppTerminalTheme,
|
||||
hostById,
|
||||
logViews,
|
||||
sessionById,
|
||||
themeById,
|
||||
workspaceById,
|
||||
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
|
||||
if (activeTabId === "vault" || activeTabId === "sftp") return null;
|
||||
|
||||
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
|
||||
if (followAppTerminalTheme) return currentTerminalTheme;
|
||||
const host = hostById.get(session.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
};
|
||||
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorTabId = fromEditorTabId(activeTabId);
|
||||
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
|
||||
if (!editorTab) return null;
|
||||
const host = hostById.get(editorTab.hostId) ?? null;
|
||||
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
|
||||
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
|
||||
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
|
||||
}
|
||||
|
||||
const logView = logViews.find((item) => item.id === activeTabId);
|
||||
if (logView) {
|
||||
const explicitThemeId = logView.log.themeId;
|
||||
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
|
||||
}
|
||||
|
||||
const workspace = workspaceById.get(activeTabId);
|
||||
if (workspace) {
|
||||
if (workspace.viewMode === "focus") {
|
||||
const workspaceSessionIds = collectSessionIds(workspace.root);
|
||||
const focusedSession = (workspace.focusedSessionId
|
||||
? sessionById.get(workspace.focusedSessionId)
|
||||
: null)
|
||||
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
|
||||
return focusedSession ? resolveSessionTheme(focusedSession) : null;
|
||||
}
|
||||
|
||||
const workspaceSessions = collectSessionIds(workspace.root)
|
||||
.map((id) => sessionById.get(id))
|
||||
.filter(Boolean) as TerminalSession[];
|
||||
if (workspaceSessions.length === 0) return null;
|
||||
|
||||
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
|
||||
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
|
||||
return allSame ? firstTheme : null;
|
||||
}
|
||||
|
||||
const session = sessionById.get(activeTabId);
|
||||
return session ? resolveSessionTheme(session) : null;
|
||||
}
|
||||
40
application/app/tabShortcutTargets.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
|
||||
|
||||
test('number shortcut tabs include vault and sftp by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: true,
|
||||
shellOnlyTabNumberShortcuts: true,
|
||||
orderedTabs: ['session-1', 'workspace-1'],
|
||||
editorTabIds: ['editor:file-1'],
|
||||
}),
|
||||
['session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('hidden sftp tab is omitted from default number shortcut targets', () => {
|
||||
assert.deepEqual(
|
||||
buildNumberShortcutTabTargets({
|
||||
showSftpTab: false,
|
||||
shellOnlyTabNumberShortcuts: false,
|
||||
orderedTabs: ['session-1'],
|
||||
editorTabIds: [],
|
||||
}),
|
||||
['vault', 'session-1'],
|
||||
);
|
||||
});
|
||||
14
application/app/tabShortcutTargets.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
|
||||
export function buildNumberShortcutTabTargets(params: {
|
||||
showSftpTab: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
orderedTabs: readonly string[];
|
||||
editorTabIds: readonly string[];
|
||||
}): string[] {
|
||||
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
|
||||
if (params.shellOnlyTabNumberShortcuts) {
|
||||
return workTabs;
|
||||
}
|
||||
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
|
||||
return [...pinnedTabs, ...workTabs];
|
||||
}
|
||||
18
application/app/topTabsChromeTheme.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
|
||||
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
|
||||
const restoreBlock = chromeThemeSource.match(
|
||||
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
|
||||
)?.[0] ?? "";
|
||||
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
|
||||
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
|
||||
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
|
||||
});
|
||||
109
application/app/topTabsChromeTheme.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { TerminalTheme } from '../../types';
|
||||
|
||||
function hexToHslToken(hex: string): string {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
|
||||
const r = parseInt(normalized.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(normalized.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(normalized.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightnessToken(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturationToken(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
|
||||
if (element.style.getPropertyValue(property) === value) return;
|
||||
element.style.setProperty(property, value);
|
||||
};
|
||||
|
||||
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
|
||||
if (!element.style.getPropertyValue(property)) return;
|
||||
element.style.removeProperty(property);
|
||||
};
|
||||
|
||||
const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--top-tabs-bg',
|
||||
'--top-tabs-fg',
|
||||
'--top-tabs-muted',
|
||||
'--top-tabs-active-bg',
|
||||
'--top-tabs-accent',
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--accent',
|
||||
'--primary',
|
||||
'--secondary',
|
||||
'--border',
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
const accent = hexToHslToken(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
|
||||
export function hasActiveChromeThemeDataset(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return Boolean(document.documentElement.dataset.activeChromeTheme);
|
||||
}
|
||||
82
application/app/workTabSurface.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildOrderedWorkTabIds,
|
||||
isHostTreeWorkTabSurface,
|
||||
isRootPageTabId,
|
||||
isTerminalContentTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
test('work tab order keeps custom positions and appends new tabs', () => {
|
||||
assert.deepEqual(
|
||||
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
|
||||
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('root pages are not work tab surfaces', () => {
|
||||
assert.equal(isRootPageTabId('vault'), true);
|
||||
assert.equal(isRootPageTabId('sftp'), true);
|
||||
assert.equal(isRootPageTabId('session-1'), false);
|
||||
});
|
||||
|
||||
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
const logViewIds = new Set(['log-1']);
|
||||
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
|
||||
|
||||
for (const activeTabId of orderedTabs) {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shared host tree recognizes active log view before tab ordering catches up', () => {
|
||||
assert.equal(isHostTreeWorkTabSurface({
|
||||
enabled: true,
|
||||
activeTabId: 'log-1',
|
||||
logViewIds: new Set(['log-1']),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('terminal content surface is limited to sessions and workspaces', () => {
|
||||
const sessionIds = new Set(['session-1']);
|
||||
const workspaceIds = new Set(['workspace-1']);
|
||||
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
|
||||
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
|
||||
});
|
||||
|
||||
test('shared host tree resolves active host ids across work tab types', () => {
|
||||
const sessions = [
|
||||
{ id: 'session-1', hostId: 'host-1' },
|
||||
{ id: 'session-2', hostId: 'host-2' },
|
||||
] as TerminalSession[];
|
||||
const workspaces = [
|
||||
{ id: 'workspace-1', focusedSessionId: 'session-2' },
|
||||
] as Workspace[];
|
||||
const editorTabs = [
|
||||
{ id: 'file-1', hostId: 'host-3' },
|
||||
] as EditorTab[];
|
||||
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
|
||||
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
|
||||
});
|
||||
87
application/app/workTabSurface.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
fromEditorTabId,
|
||||
isEditorTabId,
|
||||
} from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
export function isRootPageTabId(activeTabId: string): boolean {
|
||||
return activeTabId === 'vault' || activeTabId === 'sftp';
|
||||
}
|
||||
|
||||
export function buildOrderedWorkTabIds(
|
||||
tabOrder: readonly string[],
|
||||
allTabIds: readonly string[],
|
||||
): string[] {
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}
|
||||
|
||||
export function isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds = new Set(),
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!enabled) return false;
|
||||
if (isRootPageTabId(activeTabId)) return false;
|
||||
return orderedTabs.includes(activeTabId)
|
||||
|| isEditorTabId(activeTabId)
|
||||
|| logViewIds.has(activeTabId)
|
||||
|| sessionIds.has(activeTabId)
|
||||
|| workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function isTerminalContentTabSurface({
|
||||
activeTabId,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
|
||||
}
|
||||
|
||||
export function resolveWorkTabActiveHostId({
|
||||
activeTabId,
|
||||
editorTabs,
|
||||
sessions,
|
||||
workspaces,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
}): string | null {
|
||||
if (isEditorTabId(activeTabId)) {
|
||||
const editorId = fromEditorTabId(activeTabId);
|
||||
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeTabId);
|
||||
if (activeSession) return activeSession.hostId ?? null;
|
||||
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
|
||||
if (!activeWorkspace) return null;
|
||||
|
||||
const focusedSessionId = activeWorkspace.focusedSessionId;
|
||||
if (focusedSessionId) {
|
||||
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
const interpolate = (template: string, values?: InterpolationValues): string => {
|
||||
if (!values) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const replaceDoubleBraceToken = (match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return match;
|
||||
return String(v);
|
||||
};
|
||||
const replaceSingleBraceToken = (_match: string, key: string) => {
|
||||
const v = values[key];
|
||||
if (v === null || v === undefined) return '';
|
||||
return String(v);
|
||||
});
|
||||
};
|
||||
return template
|
||||
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
|
||||
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
|
||||
};
|
||||
|
||||
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
|
||||
import { enVaultMessages } from './en/vault';
|
||||
import { enTerminalMessages } from './en/terminal';
|
||||
import { enAiMessages } from './en/ai';
|
||||
import { enSystemManagerMessages } from './en/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const en: Messages = {
|
||||
...enVaultMessages,
|
||||
...enTerminalMessages,
|
||||
...enAiMessages,
|
||||
...enSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const enAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Uses the Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Detecting...',
|
||||
'ai.cursor.detected': 'Available',
|
||||
'ai.cursor.notFound': 'Unavailable',
|
||||
'ai.cursor.path': 'Runtime:',
|
||||
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Detected',
|
||||
'ai.cursor.notInstalled': 'Not detected',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': 'Configured',
|
||||
'ai.cursor.apiKeyMissing': 'Missing',
|
||||
'ai.cursor.apiKeyFromEnv': 'From environment',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Save',
|
||||
'ai.cursor.saved': 'Saved',
|
||||
'ai.cursor.showApiKey': 'Show API key',
|
||||
'ai.cursor.hideApiKey': 'Hide API key',
|
||||
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Check',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.codebuddy.detecting': 'Detecting...',
|
||||
'ai.codebuddy.detected': 'Detected',
|
||||
'ai.codebuddy.notFound': 'Not found',
|
||||
'ai.codebuddy.path': 'Path:',
|
||||
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Check',
|
||||
'ai.codebuddy.configSection': 'Authentication & config (optional)',
|
||||
'ai.codebuddy.internetEnv': 'Internet Environment',
|
||||
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
|
||||
'ai.codebuddy.envVars': 'Environment variables',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
@@ -127,6 +175,29 @@ export const enAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Ready',
|
||||
'ai.userSkills.status.warning': 'Warning',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Quick Messages',
|
||||
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
|
||||
'ai.quickMessages.add': 'Add Quick Message',
|
||||
'ai.quickMessages.createTitle': 'New Quick Message',
|
||||
'ai.quickMessages.editTitle': 'Edit Quick Message',
|
||||
'ai.quickMessages.name': 'Name',
|
||||
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
|
||||
'ai.quickMessages.slug': 'Command',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Description (optional)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
|
||||
'ai.quickMessages.content': 'Message content',
|
||||
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
|
||||
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
|
||||
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
|
||||
'ai.quickMessages.error.nameRequired': 'Name is required.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
|
||||
'ai.quickMessages.error.contentRequired': 'Message content is required.',
|
||||
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
|
||||
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
||||
'ai.chat.toolDenied': 'Action was rejected by the user.',
|
||||
@@ -175,6 +246,7 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.usedTools': 'Tools used: {n}',
|
||||
'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.',
|
||||
@@ -185,6 +257,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
'ai.chat.slashQuickMessages': 'Quick messages',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Slash commands',
|
||||
'ai.chat.slashNoResults': 'No matching commands',
|
||||
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
@@ -228,6 +307,7 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
'terminal.layer.history': 'History',
|
||||
'terminal.layer.theme': 'Theme',
|
||||
'terminal.layer.aiChat': 'AI Chat',
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
@@ -243,6 +323,13 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.hostTree.collapse': 'Collapse host list',
|
||||
'terminal.layer.hostTree.expand': 'Expand host list',
|
||||
'terminal.layer.hostTree.empty': 'No hosts found',
|
||||
'terminal.layer.hostTree.details.host': 'Host',
|
||||
'terminal.layer.hostTree.details.user': 'User',
|
||||
'terminal.layer.hostTree.details.port': 'Port',
|
||||
'terminal.layer.hostTree.details.protocol': 'Protocol',
|
||||
'terminal.layer.hostTree.details.group': 'Group',
|
||||
'terminal.layer.hostTree.details.tags': 'Tags',
|
||||
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
|
||||
'common.more': 'More',
|
||||
'common.selectAHost': 'Select a host',
|
||||
'common.selectAHostPlaceholder': 'Select a host...',
|
||||
'sort.manual': 'Manual order',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
@@ -225,6 +226,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Update Available',
|
||||
@@ -264,9 +267,9 @@ 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 (focus-mode terminal list), terminal-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 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 */',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
@@ -429,6 +432,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.section.systemManager': 'System Manager',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
|
||||
@@ -440,8 +452,6 @@ 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',
|
||||
@@ -466,6 +476,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Disabled',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
|
||||
'settings.shortcuts.section.custom': 'Custom Shortcuts',
|
||||
'settings.shortcuts.resetAll': 'Reset All',
|
||||
'settings.shortcuts.recording': 'Press keys...',
|
||||
@@ -667,6 +679,7 @@ export const enCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
|
||||
175
application/i18n/locales/en/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'System',
|
||||
|
||||
'systemManager.noSession': 'No active terminal session.',
|
||||
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
|
||||
'systemManager.empty': 'No data available.',
|
||||
'systemManager.tabs.processes': 'Processes',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Opening terminal…',
|
||||
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Failed to load processes',
|
||||
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
|
||||
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
|
||||
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
|
||||
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
|
||||
'systemManager.errors.actionFailed': 'Action failed',
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
|
||||
'systemManager.processes.search': 'Search processes…',
|
||||
'systemManager.processes.command': 'Command',
|
||||
'systemManager.processes.user': 'User',
|
||||
'systemManager.processes.term': 'Terminate',
|
||||
'systemManager.processes.kill': 'Kill',
|
||||
'systemManager.processes.stop': 'Stop (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Continue (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Hang up (SIGHUP)',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
|
||||
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'All',
|
||||
'systemManager.processes.filter.running': 'Running',
|
||||
'systemManager.processes.ppid': 'Parent PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Virtual size',
|
||||
'systemManager.processes.elapsed': 'Elapsed',
|
||||
'systemManager.processes.stat': 'State',
|
||||
'systemManager.processes.meta': '{{count}} process(es)',
|
||||
'systemManager.processes.state.running': 'Running',
|
||||
'systemManager.processes.state.sleeping': 'Sleeping',
|
||||
'systemManager.processes.state.stopped': 'Stopped',
|
||||
'systemManager.processes.state.zombie': 'Zombie',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'MEM',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Command',
|
||||
'systemManager.processes.sort.user': 'User',
|
||||
|
||||
'systemManager.common.dismiss': 'Dismiss',
|
||||
|
||||
'systemManager.tmux.new': 'New',
|
||||
'systemManager.tmux.search': 'Search sessions…',
|
||||
'systemManager.tmux.newSessionTitle': 'New tmux session',
|
||||
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Custom command',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
|
||||
'systemManager.tmux.pickSnippet': 'From snippets',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
|
||||
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Session name',
|
||||
'systemManager.tmux.newSessionCommand': 'Start command',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
|
||||
'systemManager.tmux.creating': 'Creating…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
|
||||
'systemManager.tmux.empty': 'No tmux sessions',
|
||||
'systemManager.tmux.attach': 'Attach',
|
||||
'systemManager.tmux.attached': 'Attached',
|
||||
'systemManager.tmux.detached': 'Detached',
|
||||
'systemManager.tmux.windows': '{{count}} window(s)',
|
||||
'systemManager.tmux.created': 'Created',
|
||||
'systemManager.tmux.activity': 'Activity',
|
||||
'systemManager.tmux.rename': 'Rename',
|
||||
'systemManager.tmux.detach': 'Detach all',
|
||||
'systemManager.tmux.killSession': 'Kill session',
|
||||
'systemManager.tmux.killServer': 'Kill server',
|
||||
'systemManager.tmux.loadingDetails': 'Loading details…',
|
||||
'systemManager.tmux.clients': 'Attached clients',
|
||||
'systemManager.tmux.windowList': 'Windows',
|
||||
'systemManager.tmux.newWindow': 'New window',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
'systemManager.tmux.panes': '{{count}} pane(s)',
|
||||
'systemManager.tmux.active': 'active',
|
||||
'systemManager.tmux.unnamedWindow': 'Unnamed window',
|
||||
'systemManager.tmux.unnamedPane': 'Unnamed pane',
|
||||
'systemManager.tmux.attachWindow': 'Attach to window',
|
||||
'systemManager.tmux.selectWindow': 'Select window',
|
||||
'systemManager.tmux.killWindow': 'Kill window',
|
||||
'systemManager.tmux.killPane': 'Kill pane',
|
||||
'systemManager.tmux.splitHorizontal': 'Split horizontal',
|
||||
'systemManager.tmux.splitVertical': 'Split vertical',
|
||||
'systemManager.tmux.sendKeys': 'Send keys',
|
||||
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Rename session',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Rename window',
|
||||
'systemManager.tmux.windowName': 'Window name',
|
||||
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
|
||||
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
|
||||
'systemManager.tmux.meta': '{{count}} session(s)',
|
||||
|
||||
'systemManager.docker.title': 'Containers',
|
||||
'systemManager.docker.subTabs.containers': 'Containers',
|
||||
'systemManager.docker.subTabs.images': 'Images',
|
||||
'systemManager.docker.empty': 'No containers found',
|
||||
'systemManager.docker.imagesEmpty': 'No images found',
|
||||
'systemManager.docker.search': 'Search containers…',
|
||||
'systemManager.docker.searchImages': 'Search images…',
|
||||
'systemManager.docker.filter.all': 'All',
|
||||
'systemManager.docker.filter.running': 'Running',
|
||||
'systemManager.docker.filter.stopped': 'Stopped',
|
||||
'systemManager.docker.filter.paused': 'Paused',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Logs',
|
||||
'systemManager.docker.details': 'Details',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Image inspect',
|
||||
'systemManager.docker.confirmRemove': 'Remove this container?',
|
||||
'systemManager.docker.confirmKill': 'Force kill this container?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
|
||||
'systemManager.docker.confirmPrune': 'Remove dangling images?',
|
||||
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
|
||||
'systemManager.docker.pause': 'Pause',
|
||||
'systemManager.docker.unpause': 'Unpause',
|
||||
'systemManager.docker.restart': 'Restart',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Container name',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Repository name',
|
||||
'systemManager.docker.tagNamePrompt': 'Tag name',
|
||||
'systemManager.docker.meta': '{{count}} container(s)',
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
|
||||
'systemManager.inspect.status': 'Status',
|
||||
'systemManager.inspect.image': 'Image',
|
||||
'systemManager.inspect.created': 'Created',
|
||||
'systemManager.inspect.started': 'Started',
|
||||
'systemManager.inspect.restartPolicy': 'Restart policy',
|
||||
'systemManager.inspect.command': 'Command',
|
||||
'systemManager.inspect.ports': 'Ports',
|
||||
'systemManager.inspect.networks': 'Networks',
|
||||
'systemManager.inspect.mounts': 'Mounts',
|
||||
'systemManager.inspect.env': 'Environment',
|
||||
'systemManager.inspect.labels': 'Labels',
|
||||
'systemManager.inspect.tags': 'Tags',
|
||||
'systemManager.inspect.digests': 'Digests',
|
||||
'systemManager.inspect.size': 'Size',
|
||||
'systemManager.inspect.platform': 'Platform',
|
||||
'systemManager.inspect.workdir': 'Working dir',
|
||||
'systemManager.inspect.exposedPorts': 'Exposed ports',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Hide JSON',
|
||||
};
|
||||
@@ -5,9 +5,26 @@ export const enTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'More actions',
|
||||
'terminal.toolbar.scripts': 'Scripts',
|
||||
'terminal.toolbar.history': 'Command history',
|
||||
'history.scope.label': 'History scope',
|
||||
'history.tab.host': 'Host',
|
||||
'history.tab.global': 'Global',
|
||||
'history.searchPlaceholder': 'Search history...',
|
||||
'history.loading': 'Loading remote history...',
|
||||
'history.meta.count': '{count} commands',
|
||||
'history.empty.noSession': 'Open a remote session to view its command history.',
|
||||
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
|
||||
'history.empty.noHistory': 'No command history found on this host.',
|
||||
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
|
||||
'history.action.refresh': 'Refresh',
|
||||
'history.action.retry': 'Retry',
|
||||
'history.action.paste': 'Paste to terminal',
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
@@ -21,6 +38,14 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
|
||||
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
|
||||
'terminal.composeBar.pinnedCount': '{count} pinned',
|
||||
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
|
||||
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
@@ -75,10 +100,17 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.reconnect': 'Reconnect',
|
||||
'terminal.menu.sendYmodem': 'Send with YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
'terminal.menu.clearBuffer': 'Clear Buffer',
|
||||
'terminal.menu.closeTerminal': 'Close terminal',
|
||||
'terminal.ymodem.selectFile': 'Select file to send',
|
||||
'terminal.ymodem.allFiles': 'All files',
|
||||
'terminal.ymodem.started': 'YMODEM sending {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM send failed',
|
||||
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
|
||||
'terminal.selection.addToAI': 'Add to Conversation',
|
||||
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
|
||||
'terminal.auth.password': 'Password',
|
||||
|
||||
@@ -123,6 +123,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.list': 'Bookmarked paths',
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
@@ -153,6 +154,8 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.viewMode.label': 'View mode',
|
||||
'sftp.viewMode.list': 'List view',
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -527,6 +530,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
|
||||
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
|
||||
'hostDetails.lineTimestamps': 'Prefix output with timestamps',
|
||||
'hostDetails.lineTimestamps.desc': 'Add local time before visible output lines for this host. Disable it for prompts that render incorrectly when output is prefixed.',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
|
||||
import { ruVaultMessages } from './ru/vault';
|
||||
import { ruTerminalMessages } from './ru/terminal';
|
||||
import { ruAiMessages } from './ru/ai';
|
||||
import { ruSystemManagerMessages } from './ru/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const ru: Messages = {
|
||||
...ruVaultMessages,
|
||||
...ruTerminalMessages,
|
||||
...ruAiMessages,
|
||||
...ruSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const ruAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Проверить',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': 'Использует Cursor SDK.',
|
||||
'ai.cursor.detecting': 'Обнаружение...',
|
||||
'ai.cursor.detected': 'Доступен',
|
||||
'ai.cursor.notFound': 'Недоступен',
|
||||
'ai.cursor.path': 'Среда:',
|
||||
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
|
||||
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': 'Обнаружено',
|
||||
'ai.cursor.notInstalled': 'Не обнаружено',
|
||||
'ai.cursor.apiKeyStatus': 'API-ключ',
|
||||
'ai.cursor.apiKeyConfigured': 'Настроен',
|
||||
'ai.cursor.apiKeyMissing': 'Не указан',
|
||||
'ai.cursor.apiKeyFromEnv': 'Из окружения',
|
||||
'ai.cursor.apiKey': 'API-ключ',
|
||||
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
|
||||
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
|
||||
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
|
||||
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
|
||||
'ai.cursor.saveApiKey': 'Сохранить',
|
||||
'ai.cursor.saved': 'Сохранено',
|
||||
'ai.cursor.showApiKey': 'Показать API-ключ',
|
||||
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
|
||||
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
|
||||
'ai.cursor.check': 'Проверить',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
|
||||
'ai.codebuddy.detecting': 'Обнаружение...',
|
||||
'ai.codebuddy.detected': 'Обнаружен',
|
||||
'ai.codebuddy.notFound': 'Не найден',
|
||||
'ai.codebuddy.path': 'Путь:',
|
||||
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
|
||||
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': 'Проверить',
|
||||
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
|
||||
'ai.codebuddy.internetEnv': 'Сетевая среда',
|
||||
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
|
||||
'ai.codebuddy.envVars': 'Переменные окружения',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Агент по умолчанию',
|
||||
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
|
||||
@@ -127,6 +175,29 @@ export const ruAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': 'Готово',
|
||||
'ai.userSkills.status.warning': 'Предупреждение',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': 'Быстрые сообщения',
|
||||
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
|
||||
'ai.quickMessages.add': 'Добавить быстрое сообщение',
|
||||
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
|
||||
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
|
||||
'ai.quickMessages.name': 'Название',
|
||||
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
|
||||
'ai.quickMessages.slug': 'Команда',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
|
||||
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
|
||||
'ai.quickMessages.content': 'Текст сообщения',
|
||||
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
|
||||
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
|
||||
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
|
||||
'ai.quickMessages.error.nameRequired': 'Укажите название.',
|
||||
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
|
||||
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
|
||||
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
|
||||
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
|
||||
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
|
||||
@@ -175,6 +246,7 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.usedTools': 'Использовано инструментов: {n}',
|
||||
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
@@ -185,6 +257,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские skills',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
|
||||
'ai.chat.slashUserSkills': 'User skills',
|
||||
'ai.chat.quickMessages': 'Команды /',
|
||||
'ai.chat.slashNoResults': 'Нет подходящих команд',
|
||||
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
@@ -228,6 +307,7 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
'terminal.layer.history': 'История',
|
||||
'terminal.layer.theme': 'Тема',
|
||||
'terminal.layer.aiChat': 'AI-чат',
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
|
||||
'common.more': 'Ещё',
|
||||
'common.selectAHost': 'Выберите хост',
|
||||
'common.selectAHostPlaceholder': 'Выберите хост...',
|
||||
'sort.manual': 'Ручной порядок',
|
||||
'sort.az': 'А-Я',
|
||||
'sort.za': 'Я-А',
|
||||
'sort.newest': 'Сначала новые',
|
||||
@@ -225,6 +226,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': 'Доступно обновление',
|
||||
@@ -264,9 +267,9 @@ 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 (список терминалов в режиме Focus), terminal-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !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/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
@@ -429,6 +432,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.section.systemManager': 'Системный менеджер',
|
||||
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
|
||||
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
|
||||
@@ -440,8 +452,6 @@ 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': 'Индикатор фокуса рабочей области',
|
||||
@@ -466,6 +476,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': 'Отключено',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
|
||||
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
|
||||
'settings.shortcuts.resetAll': 'Сбросить все',
|
||||
'settings.shortcuts.recording': 'Нажмите клавиши...',
|
||||
|
||||
175
application/i18n/locales/ru/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': 'Система',
|
||||
|
||||
'systemManager.noSession': 'Нет активного терминального сеанса.',
|
||||
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
|
||||
'systemManager.empty': 'Нет данных.',
|
||||
'systemManager.tabs.processes': 'Процессы',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': 'Открытие терминала…',
|
||||
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
|
||||
|
||||
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
|
||||
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
|
||||
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
|
||||
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
|
||||
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
|
||||
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
|
||||
'systemManager.processes.search': 'Поиск процессов…',
|
||||
'systemManager.processes.command': 'Команда',
|
||||
'systemManager.processes.user': 'Пользователь',
|
||||
'systemManager.processes.term': 'Завершить',
|
||||
'systemManager.processes.kill': 'Убить',
|
||||
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
|
||||
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
|
||||
'systemManager.processes.hup': 'Сигнал SIGHUP',
|
||||
'systemManager.processes.renice': 'Renice',
|
||||
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
|
||||
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
|
||||
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
|
||||
'systemManager.processes.filter.all': 'Все',
|
||||
'systemManager.processes.filter.running': 'Активные',
|
||||
'systemManager.processes.ppid': 'Родительский PID',
|
||||
'systemManager.processes.rss': 'RSS',
|
||||
'systemManager.processes.vsz': 'Виртуальный размер',
|
||||
'systemManager.processes.elapsed': 'Время работы',
|
||||
'systemManager.processes.stat': 'Состояние',
|
||||
'systemManager.processes.meta': '{{count}} проц.',
|
||||
'systemManager.processes.state.running': 'Активен',
|
||||
'systemManager.processes.state.sleeping': 'Сон',
|
||||
'systemManager.processes.state.stopped': 'Остановлен',
|
||||
'systemManager.processes.state.zombie': 'Зомби',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': 'Память',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': 'Команда',
|
||||
'systemManager.processes.sort.user': 'Пользователь',
|
||||
|
||||
'systemManager.common.dismiss': 'Закрыть',
|
||||
|
||||
'systemManager.tmux.new': 'Создать',
|
||||
'systemManager.tmux.search': 'Поиск сессий…',
|
||||
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
|
||||
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
|
||||
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
|
||||
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
|
||||
'systemManager.tmux.pickSnippet': 'Из сниппетов',
|
||||
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
|
||||
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
|
||||
'systemManager.tmux.newSessionName': 'Имя сессии',
|
||||
'systemManager.tmux.newSessionCommand': 'Команда запуска',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
|
||||
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
|
||||
'systemManager.tmux.creating': 'Создание…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
|
||||
'systemManager.tmux.empty': 'Нет сессий tmux',
|
||||
'systemManager.tmux.attach': 'Подключить',
|
||||
'systemManager.tmux.attached': 'Подключена',
|
||||
'systemManager.tmux.detached': 'Отключена',
|
||||
'systemManager.tmux.windows': '{{count}} окон',
|
||||
'systemManager.tmux.created': 'Создана',
|
||||
'systemManager.tmux.activity': 'Активность',
|
||||
'systemManager.tmux.rename': 'Переименовать',
|
||||
'systemManager.tmux.detach': 'Отключить всех',
|
||||
'systemManager.tmux.killSession': 'Завершить сессию',
|
||||
'systemManager.tmux.killServer': 'Остановить сервер',
|
||||
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
|
||||
'systemManager.tmux.clients': 'Подключённые клиенты',
|
||||
'systemManager.tmux.windowList': 'Окна',
|
||||
'systemManager.tmux.newWindow': 'Новое окно',
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
'systemManager.tmux.panes': '{{count}} пан.',
|
||||
'systemManager.tmux.active': 'активно',
|
||||
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
|
||||
'systemManager.tmux.unnamedPane': 'Безымянная панель',
|
||||
'systemManager.tmux.attachWindow': 'Подключить к окну',
|
||||
'systemManager.tmux.selectWindow': 'Выбрать окно',
|
||||
'systemManager.tmux.killWindow': 'Закрыть окно',
|
||||
'systemManager.tmux.killPane': 'Закрыть панель',
|
||||
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
|
||||
'systemManager.tmux.splitVertical': 'Разделить вертикально',
|
||||
'systemManager.tmux.sendKeys': 'Отправить клавиши',
|
||||
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
|
||||
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
|
||||
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
|
||||
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
|
||||
'systemManager.tmux.windowName': 'Имя окна',
|
||||
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
|
||||
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
|
||||
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
|
||||
'systemManager.tmux.meta': '{{count}} сессий',
|
||||
|
||||
'systemManager.docker.title': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.containers': 'Контейнеры',
|
||||
'systemManager.docker.subTabs.images': 'Образы',
|
||||
'systemManager.docker.empty': 'Контейнеры не найдены',
|
||||
'systemManager.docker.imagesEmpty': 'Образы не найдены',
|
||||
'systemManager.docker.search': 'Поиск контейнеров…',
|
||||
'systemManager.docker.searchImages': 'Поиск образов…',
|
||||
'systemManager.docker.filter.all': 'Все',
|
||||
'systemManager.docker.filter.running': 'Запущены',
|
||||
'systemManager.docker.filter.stopped': 'Остановлены',
|
||||
'systemManager.docker.filter.paused': 'На паузе',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': 'Логи',
|
||||
'systemManager.docker.details': 'Детали',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': 'Inspect образа',
|
||||
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
|
||||
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
|
||||
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
|
||||
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
|
||||
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
|
||||
'systemManager.docker.pause': 'Пауза',
|
||||
'systemManager.docker.unpause': 'Возобновить',
|
||||
'systemManager.docker.restart': 'Перезапустить',
|
||||
'systemManager.docker.kill': 'Kill',
|
||||
'systemManager.docker.renamePrompt': 'Имя контейнера',
|
||||
'systemManager.docker.prune': 'Prune',
|
||||
'systemManager.docker.pruneAll': 'Prune all',
|
||||
'systemManager.docker.tag': 'Tag',
|
||||
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
|
||||
'systemManager.docker.tagNamePrompt': 'Имя тега',
|
||||
'systemManager.docker.meta': '{{count}} конт.',
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
|
||||
'systemManager.inspect.status': 'Статус',
|
||||
'systemManager.inspect.image': 'Образ',
|
||||
'systemManager.inspect.created': 'Создан',
|
||||
'systemManager.inspect.started': 'Запущен',
|
||||
'systemManager.inspect.restartPolicy': 'Перезапуск',
|
||||
'systemManager.inspect.command': 'Команда',
|
||||
'systemManager.inspect.ports': 'Порты',
|
||||
'systemManager.inspect.networks': 'Сети',
|
||||
'systemManager.inspect.mounts': 'Тома',
|
||||
'systemManager.inspect.env': 'Окружение',
|
||||
'systemManager.inspect.labels': 'Метки',
|
||||
'systemManager.inspect.tags': 'Теги',
|
||||
'systemManager.inspect.digests': 'Дайджесты',
|
||||
'systemManager.inspect.size': 'Размер',
|
||||
'systemManager.inspect.platform': 'Платформа',
|
||||
'systemManager.inspect.workdir': 'Рабочий каталог',
|
||||
'systemManager.inspect.exposedPorts': 'Открытые порты',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': 'Скрыть JSON',
|
||||
};
|
||||
@@ -26,9 +26,26 @@ export const ruTerminalMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Открыть SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
|
||||
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': 'Другие действия',
|
||||
'terminal.toolbar.scripts': 'Скрипты',
|
||||
'terminal.toolbar.history': 'История команд',
|
||||
'history.scope.label': 'Область истории',
|
||||
'history.tab.host': 'Хост',
|
||||
'history.tab.global': 'Глобальная',
|
||||
'history.searchPlaceholder': 'Поиск по истории...',
|
||||
'history.loading': 'Загрузка удалённой истории...',
|
||||
'history.meta.count': '{count} команд',
|
||||
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
|
||||
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
|
||||
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
|
||||
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
|
||||
'history.action.refresh': 'Обновить',
|
||||
'history.action.retry': 'Повторить',
|
||||
'history.action.paste': 'Вставить в терминал',
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
@@ -42,6 +59,14 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
|
||||
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
|
||||
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
|
||||
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
|
||||
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
|
||||
'terminal.toolbar.focus': 'Фокус',
|
||||
'terminal.toolbar.focusMode': 'Режим фокуса',
|
||||
'terminal.toolbar.encoding': 'Кодировка терминала',
|
||||
@@ -96,10 +121,17 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': 'Вставить выделенное',
|
||||
'terminal.menu.selectAll': 'Выбрать всё',
|
||||
'terminal.menu.reconnect': 'Переподключиться',
|
||||
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
|
||||
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
|
||||
'terminal.menu.splitVertical': 'Разделить по вертикали',
|
||||
'terminal.menu.clearBuffer': 'Очистить буфер',
|
||||
'terminal.menu.closeTerminal': 'Закрыть терминал',
|
||||
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
|
||||
'terminal.ymodem.allFiles': 'Все файлы',
|
||||
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
|
||||
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
|
||||
'terminal.ymodem.unavailable': 'YMODEM недоступен',
|
||||
'terminal.selection.addToAI': 'Добавить в чат',
|
||||
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
|
||||
'terminal.auth.password': 'Пароль',
|
||||
|
||||
@@ -158,6 +158,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.filter.placeholder': 'Фильтр по имени файла...',
|
||||
'sftp.bookmark.add': 'Добавить путь в закладки',
|
||||
'sftp.bookmark.remove': 'Удалить закладку',
|
||||
'sftp.bookmark.list': 'Закладки путей',
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
@@ -188,6 +189,8 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.viewMode.label': 'Режим просмотра',
|
||||
'sftp.viewMode.list': 'Список',
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.viewMode.switchToList': 'Переключиться на список',
|
||||
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
@@ -559,6 +562,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
|
||||
'hostDetails.section.terminalBehavior': 'Поведение терминала',
|
||||
'hostDetails.lineTimestamps': 'Добавлять время к выводу',
|
||||
'hostDetails.lineTimestamps.desc': 'Добавлять локальное время перед видимыми строками вывода только для этого хоста. Отключите, если из-за этого некорректно отображается приглашение.',
|
||||
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
|
||||
import { zhCNVaultMessages } from './zh-CN/vault';
|
||||
import { zhCNTerminalMessages } from './zh-CN/terminal';
|
||||
import { zhCNAiMessages } from './zh-CN/ai';
|
||||
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
|
||||
|
||||
export type { Messages } from './types';
|
||||
|
||||
@@ -11,6 +12,7 @@ const zhCN: Messages = {
|
||||
...zhCNVaultMessages,
|
||||
...zhCNTerminalMessages,
|
||||
...zhCNAiMessages,
|
||||
...zhCnSystemManagerMessages,
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -106,6 +106,54 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Cursor SDK
|
||||
'ai.cursor.title': 'Cursor',
|
||||
'ai.cursor.description': '使用 Cursor SDK。',
|
||||
'ai.cursor.detecting': '检测中...',
|
||||
'ai.cursor.detected': '可用',
|
||||
'ai.cursor.notFound': '不可用',
|
||||
'ai.cursor.path': '运行环境:',
|
||||
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
|
||||
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
|
||||
'ai.cursor.installStatus': 'Cursor SDK',
|
||||
'ai.cursor.installed': '已检测到',
|
||||
'ai.cursor.notInstalled': '未检测到',
|
||||
'ai.cursor.apiKeyStatus': 'API Key',
|
||||
'ai.cursor.apiKeyConfigured': '已填写',
|
||||
'ai.cursor.apiKeyMissing': '未填写',
|
||||
'ai.cursor.apiKeyFromEnv': '来自环境变量',
|
||||
'ai.cursor.apiKey': 'API Key',
|
||||
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
|
||||
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY;填写后会覆盖',
|
||||
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用,填写保存后会覆盖它。',
|
||||
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key;清空保存后会回到 CURSOR_API_KEY。',
|
||||
'ai.cursor.saveApiKey': '保存',
|
||||
'ai.cursor.saved': '已保存',
|
||||
'ai.cursor.showApiKey': '显示 API Key',
|
||||
'ai.cursor.hideApiKey': '隐藏 API Key',
|
||||
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
|
||||
'ai.cursor.check': '检查',
|
||||
|
||||
// AI CodeBuddy Code
|
||||
'ai.codebuddy.title': 'CodeBuddy Code',
|
||||
'ai.codebuddy.description': '通过官方 Agent SDK(`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.codebuddy.detecting': '检测中...',
|
||||
'ai.codebuddy.detected': '已检测到',
|
||||
'ai.codebuddy.notFound': '未找到',
|
||||
'ai.codebuddy.path': '路径:',
|
||||
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
|
||||
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
|
||||
'ai.codebuddy.check': '检查',
|
||||
'ai.codebuddy.configSection': '认证与配置(可选)',
|
||||
'ai.codebuddy.internetEnv': '网络环境',
|
||||
'ai.codebuddy.internetEnv.default': '默认(海外)',
|
||||
'ai.codebuddy.internetEnv.internal': 'Internal',
|
||||
'ai.codebuddy.internetEnv.ioa': 'IOA',
|
||||
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
|
||||
'ai.codebuddy.envVars': '环境变量',
|
||||
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
|
||||
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
@@ -127,6 +175,29 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.userSkills.status.ready': '正常',
|
||||
'ai.userSkills.status.warning': '警告',
|
||||
|
||||
// AI Quick Messages
|
||||
'ai.quickMessages.title': '快捷消息',
|
||||
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
|
||||
'ai.quickMessages.add': '添加快捷消息',
|
||||
'ai.quickMessages.createTitle': '新建快捷消息',
|
||||
'ai.quickMessages.editTitle': '编辑快捷消息',
|
||||
'ai.quickMessages.name': '名称',
|
||||
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
|
||||
'ai.quickMessages.slug': '命令',
|
||||
'ai.quickMessages.slug.placeholder': 'disk-check',
|
||||
'ai.quickMessages.descriptionField': '说明(可选)',
|
||||
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
|
||||
'ai.quickMessages.content': '消息内容',
|
||||
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
|
||||
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
|
||||
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
|
||||
'ai.quickMessages.error.nameRequired': '请填写名称。',
|
||||
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
|
||||
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
|
||||
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
|
||||
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
|
||||
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
|
||||
|
||||
// AI Chat
|
||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||
'ai.chat.toolDenied': '操作已被用户拒绝。',
|
||||
@@ -175,6 +246,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.usedTools': '已使用 {n} 个工具',
|
||||
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
@@ -185,6 +257,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
'ai.chat.slashQuickMessages': '快捷消息',
|
||||
'ai.chat.slashUserSkills': '用户 Skills',
|
||||
'ai.chat.quickMessages': '快捷命令',
|
||||
'ai.chat.slashNoResults': '没有匹配的命令',
|
||||
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
@@ -228,6 +307,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
'terminal.layer.history': '命令历史',
|
||||
'terminal.layer.theme': '主题',
|
||||
'terminal.layer.aiChat': 'AI 助手',
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
@@ -243,6 +323,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.hostTree.collapse': '收起主机列表',
|
||||
'terminal.layer.hostTree.expand': '展开主机列表',
|
||||
'terminal.layer.hostTree.empty': '没有匹配的主机',
|
||||
'terminal.layer.hostTree.details.host': '主机',
|
||||
'terminal.layer.hostTree.details.user': '用户',
|
||||
'terminal.layer.hostTree.details.port': '端口',
|
||||
'terminal.layer.hostTree.details.protocol': '协议',
|
||||
'terminal.layer.hostTree.details.group': '分组',
|
||||
'terminal.layer.hostTree.details.tags': '标签',
|
||||
'terminal.layer.hostTree.details.lastConnected': '最近连接',
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'common.right': '右侧',
|
||||
'common.more': '更多',
|
||||
'common.selectAHost': '选择主机',
|
||||
'sort.manual': '手动顺序',
|
||||
'sort.az': 'A-z',
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
@@ -209,6 +210,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
|
||||
// Update notifications
|
||||
'update.available.title': '发现新版本',
|
||||
@@ -248,9 +251,9 @@ 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(Focus 模式终端列表)、terminal-side-panel(SFTP/脚本/主题/AI 侧栏)、terminal-sftp-panel、terminal-split-pane、terminal-split-resizer、top-tabs。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panel(SFTP/脚本/主题/AI 侧栏,打开时生效)、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 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/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
@@ -441,6 +444,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
@@ -541,6 +545,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.list': '收藏路径',
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
@@ -571,6 +576,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.viewMode.label': '视图模式',
|
||||
'sftp.viewMode.list': '列表视图',
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.viewMode.switchToList': '切换到列表视图',
|
||||
'sftp.viewMode.switchToTree': '切换到树形视图',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
|
||||
175
application/i18n/locales/zh-CN/systemManager.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCnSystemManagerMessages: Messages = {
|
||||
'terminal.layer.system': '系统',
|
||||
|
||||
'systemManager.noSession': '没有活动的终端会话。',
|
||||
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
|
||||
'systemManager.empty': '暂无数据。',
|
||||
'systemManager.tabs.processes': '进程',
|
||||
'systemManager.tabs.tmux': 'tmux',
|
||||
'systemManager.tabs.docker': 'Docker',
|
||||
'systemManager.popup.loading': '正在打开终端…',
|
||||
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
|
||||
|
||||
'systemManager.errors.loadProcesses': '加载进程列表失败',
|
||||
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
|
||||
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
|
||||
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
|
||||
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
|
||||
'systemManager.errors.actionFailed': '操作失败',
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
|
||||
'systemManager.processes.search': '搜索进程…',
|
||||
'systemManager.processes.command': '命令',
|
||||
'systemManager.processes.user': '用户',
|
||||
'systemManager.processes.term': '终止',
|
||||
'systemManager.processes.kill': '强杀',
|
||||
'systemManager.processes.stop': '暂停 (SIGSTOP)',
|
||||
'systemManager.processes.cont': '继续 (SIGCONT)',
|
||||
'systemManager.processes.hup': '挂断 (SIGHUP)',
|
||||
'systemManager.processes.renice': '调整优先级',
|
||||
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
|
||||
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
|
||||
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL?',
|
||||
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}?',
|
||||
'systemManager.processes.filter.all': '全部',
|
||||
'systemManager.processes.filter.running': '运行中',
|
||||
'systemManager.processes.ppid': '父进程 PID',
|
||||
'systemManager.processes.rss': '物理内存',
|
||||
'systemManager.processes.vsz': '虚拟内存',
|
||||
'systemManager.processes.elapsed': '运行时长',
|
||||
'systemManager.processes.stat': '状态',
|
||||
'systemManager.processes.meta': '{{count}} 个进程',
|
||||
'systemManager.processes.state.running': '运行中',
|
||||
'systemManager.processes.state.sleeping': '睡眠',
|
||||
'systemManager.processes.state.stopped': '已暂停',
|
||||
'systemManager.processes.state.zombie': '僵尸',
|
||||
'systemManager.processes.sort.cpu': 'CPU',
|
||||
'systemManager.processes.sort.mem': '内存',
|
||||
'systemManager.processes.sort.pid': 'PID',
|
||||
'systemManager.processes.sort.command': '命令',
|
||||
'systemManager.processes.sort.user': '用户',
|
||||
|
||||
'systemManager.common.dismiss': '关闭',
|
||||
|
||||
'systemManager.tmux.new': '新建',
|
||||
'systemManager.tmux.search': '搜索会话…',
|
||||
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
|
||||
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
|
||||
'systemManager.tmux.newSessionTabCustom': '自定义命令',
|
||||
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
|
||||
'systemManager.tmux.pickSnippet': '从代码片段选择',
|
||||
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
|
||||
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
|
||||
'systemManager.tmux.newSessionName': '会话名称',
|
||||
'systemManager.tmux.newSessionCommand': '启动命令',
|
||||
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev(可选)',
|
||||
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
|
||||
'systemManager.tmux.creating': '创建中…',
|
||||
'systemManager.tmux.newSessionPlaceholder': 'my-session',
|
||||
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
|
||||
'systemManager.tmux.empty': '没有 tmux 会话',
|
||||
'systemManager.tmux.attach': '附加',
|
||||
'systemManager.tmux.attached': '已附加',
|
||||
'systemManager.tmux.detached': '未附加',
|
||||
'systemManager.tmux.windows': '{{count}} 个窗口',
|
||||
'systemManager.tmux.created': '创建时间',
|
||||
'systemManager.tmux.activity': '活动时间',
|
||||
'systemManager.tmux.rename': '重命名',
|
||||
'systemManager.tmux.detach': '全部分离',
|
||||
'systemManager.tmux.killSession': '结束会话',
|
||||
'systemManager.tmux.killServer': '结束 tmux 服务',
|
||||
'systemManager.tmux.loadingDetails': '正在加载详情…',
|
||||
'systemManager.tmux.clients': '已附加客户端',
|
||||
'systemManager.tmux.windowList': '窗口',
|
||||
'systemManager.tmux.newWindow': '新建窗口',
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
'systemManager.tmux.panes': '{{count}} 个面板',
|
||||
'systemManager.tmux.active': '当前',
|
||||
'systemManager.tmux.unnamedWindow': '未命名窗口',
|
||||
'systemManager.tmux.unnamedPane': '未命名面板',
|
||||
'systemManager.tmux.attachWindow': '附加到窗口',
|
||||
'systemManager.tmux.selectWindow': '选中窗口',
|
||||
'systemManager.tmux.killWindow': '关闭窗口',
|
||||
'systemManager.tmux.killPane': '关闭面板',
|
||||
'systemManager.tmux.splitHorizontal': '水平分屏',
|
||||
'systemManager.tmux.splitVertical': '垂直分屏',
|
||||
'systemManager.tmux.sendKeys': '发送按键',
|
||||
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
|
||||
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
|
||||
'systemManager.tmux.renameSessionPrompt': '重命名会话',
|
||||
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
|
||||
'systemManager.tmux.windowName': '窗口名称',
|
||||
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
|
||||
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
|
||||
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
|
||||
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}?',
|
||||
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
|
||||
'systemManager.tmux.meta': '{{count}} 个会话',
|
||||
|
||||
'systemManager.docker.title': '容器',
|
||||
'systemManager.docker.subTabs.containers': '容器',
|
||||
'systemManager.docker.subTabs.images': '镜像',
|
||||
'systemManager.docker.empty': '未找到容器',
|
||||
'systemManager.docker.imagesEmpty': '未找到镜像',
|
||||
'systemManager.docker.search': '搜索容器…',
|
||||
'systemManager.docker.searchImages': '搜索镜像…',
|
||||
'systemManager.docker.filter.all': '全部',
|
||||
'systemManager.docker.filter.running': '运行中',
|
||||
'systemManager.docker.filter.stopped': '已停止',
|
||||
'systemManager.docker.filter.paused': '已暂停',
|
||||
'systemManager.docker.shell': 'Shell',
|
||||
'systemManager.docker.logs': '日志',
|
||||
'systemManager.docker.details': '详情',
|
||||
'systemManager.docker.inspect': 'Inspect',
|
||||
'systemManager.docker.imageInspect': '镜像 Inspect',
|
||||
'systemManager.docker.confirmRemove': '确定删除此容器?',
|
||||
'systemManager.docker.confirmKill': '确定强制终止此容器?',
|
||||
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
|
||||
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
|
||||
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
|
||||
'systemManager.docker.pause': '暂停',
|
||||
'systemManager.docker.unpause': '恢复',
|
||||
'systemManager.docker.restart': '重启',
|
||||
'systemManager.docker.kill': '强杀',
|
||||
'systemManager.docker.renamePrompt': '容器名称',
|
||||
'systemManager.docker.prune': '清理悬空',
|
||||
'systemManager.docker.pruneAll': '清理全部',
|
||||
'systemManager.docker.tag': '打标签',
|
||||
'systemManager.docker.tagRepoPrompt': '仓库名',
|
||||
'systemManager.docker.tagNamePrompt': '标签名',
|
||||
'systemManager.docker.meta': '{{count}} 个容器',
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
|
||||
'systemManager.inspect.status': '状态',
|
||||
'systemManager.inspect.image': '镜像',
|
||||
'systemManager.inspect.created': '创建时间',
|
||||
'systemManager.inspect.started': '启动时间',
|
||||
'systemManager.inspect.restartPolicy': '重启策略',
|
||||
'systemManager.inspect.command': '启动命令',
|
||||
'systemManager.inspect.ports': '端口映射',
|
||||
'systemManager.inspect.networks': '网络',
|
||||
'systemManager.inspect.mounts': '挂载',
|
||||
'systemManager.inspect.env': '环境变量',
|
||||
'systemManager.inspect.labels': '标签',
|
||||
'systemManager.inspect.tags': '镜像标签',
|
||||
'systemManager.inspect.digests': '摘要',
|
||||
'systemManager.inspect.size': '大小',
|
||||
'systemManager.inspect.platform': '平台',
|
||||
'systemManager.inspect.workdir': '工作目录',
|
||||
'systemManager.inspect.exposedPorts': '暴露端口',
|
||||
'systemManager.inspect.showRaw': 'JSON',
|
||||
'systemManager.inspect.hideRaw': '收起 JSON',
|
||||
};
|
||||
@@ -5,6 +5,22 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
// Command history side panel
|
||||
'history.scope.label': '历史范围',
|
||||
'history.tab.host': '主机',
|
||||
'history.tab.global': '全局',
|
||||
'history.searchPlaceholder': '搜索历史命令...',
|
||||
'history.loading': '正在读取远程历史...',
|
||||
'history.meta.count': '{count} 条',
|
||||
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
|
||||
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
|
||||
'history.empty.noHistory': '该主机上未找到命令历史。',
|
||||
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
|
||||
'history.action.refresh': '刷新',
|
||||
'history.action.retry': '重试',
|
||||
'history.action.paste': '粘贴到终端',
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -286,14 +302,21 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
|
||||
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
|
||||
'settings.terminal.serverStats.seconds': '秒',
|
||||
'settings.terminal.section.systemManager': '系统管理',
|
||||
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
// Settings > Terminal > Rendering
|
||||
'settings.terminal.section.rendering': '渲染',
|
||||
'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': '自动补全',
|
||||
@@ -311,6 +334,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.scheme.disabled': '禁用',
|
||||
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
|
||||
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
|
||||
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
|
||||
'settings.shortcuts.section.custom': '自定义快捷键',
|
||||
'settings.shortcuts.resetAll': '全部重置',
|
||||
'settings.shortcuts.recording': '请按键...',
|
||||
|
||||
@@ -113,6 +113,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
|
||||
'hostDetails.section.sshAlgorithms': 'SSH 算法',
|
||||
'hostDetails.section.terminalBehavior': '终端行为',
|
||||
'hostDetails.lineTimestamps': '给输出加时间戳',
|
||||
'hostDetails.lineTimestamps.desc': '仅为这个主机的终端输出行添加本地时间。如果提示符因此渲染异常,请关闭。',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
@@ -213,9 +215,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': '打开 SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': '连接后可用',
|
||||
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.toolbar.sftp': 'SFTP',
|
||||
'terminal.toolbar.more': '更多操作',
|
||||
'terminal.toolbar.scripts': '脚本',
|
||||
'terminal.toolbar.history': '命令历史',
|
||||
'terminal.toolbar.library': '库',
|
||||
'terminal.toolbar.noSnippets': '暂无代码片段',
|
||||
'terminal.toolbar.terminalSettings': '终端设置',
|
||||
@@ -229,6 +233,14 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
|
||||
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
|
||||
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
|
||||
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
|
||||
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
@@ -283,10 +295,17 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.reconnect': '重新连接',
|
||||
'terminal.menu.sendYmodem': 'YMODEM 发送',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
'terminal.menu.clearBuffer': '清空缓冲区',
|
||||
'terminal.menu.closeTerminal': '关闭终端',
|
||||
'terminal.ymodem.selectFile': '选择要发送的文件',
|
||||
'terminal.ymodem.allFiles': '所有文件',
|
||||
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
|
||||
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
|
||||
'terminal.ymodem.failed': 'YMODEM 发送失败',
|
||||
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
|
||||
'terminal.selection.addToAI': '添加到对话',
|
||||
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
|
||||
'terminal.auth.password': '密码',
|
||||
|
||||
20
application/state/activeChromeThemeSync.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("active tab changes notify chrome theme before react subscribers", () => {
|
||||
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
|
||||
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
|
||||
|
||||
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
|
||||
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
|
||||
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
|
||||
assert.ok(
|
||||
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
|
||||
"sync chrome theme listeners must run before deferred react notify",
|
||||
);
|
||||
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
|
||||
assert.match(syncSource, /isActiveChromeThemeResolvable/);
|
||||
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
|
||||
});
|
||||
39
application/state/activeChromeThemeSync.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
|
||||
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import type { EditorTab } from './editorTabStore';
|
||||
import type { LogView } from './logViewState';
|
||||
import { syncActiveChromeTheme } from './useActiveChromeTheme';
|
||||
|
||||
export type ActiveChromeThemeDeps = {
|
||||
accentMode: 'theme' | 'custom';
|
||||
applyAppTheme: () => void;
|
||||
currentTerminalTheme: TerminalTheme;
|
||||
customAccent: string;
|
||||
editorTabs: readonly EditorTab[];
|
||||
followAppTerminalTheme: boolean;
|
||||
hostById: Map<string, Host>;
|
||||
logViews: readonly LogView[];
|
||||
sessionById: Map<string, TerminalSession>;
|
||||
themeById: Map<string, TerminalTheme>;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
};
|
||||
|
||||
let depsRef: ActiveChromeThemeDeps | null = null;
|
||||
|
||||
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
|
||||
depsRef = deps;
|
||||
}
|
||||
|
||||
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
|
||||
if (!depsRef || typeof document === 'undefined') return;
|
||||
if (activeTabId === 'vault' || activeTabId === 'sftp') {
|
||||
clearTopTabsChromeThemeVars();
|
||||
}
|
||||
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
|
||||
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
|
||||
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
|
||||
}
|
||||
|
||||
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);
|
||||
14
application/state/activeTabStore.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
|
||||
|
||||
test('editor tab helpers round trip ids', () => {
|
||||
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
|
||||
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
|
||||
});
|
||||
|
||||
test('editor tab helper detects editor top-tab ids', () => {
|
||||
assert.equal(isEditorTabId('editor:file-1'), true);
|
||||
assert.equal(isEditorTabId('session-1'), false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
|
||||
|
||||
// Simple store for active tab that allows fine-grained subscriptions
|
||||
type Listener = () => void;
|
||||
type SyncListener = (activeTabId: string) => void;
|
||||
|
||||
// ----- Editor tab id helpers -----
|
||||
export const EDITOR_PREFIX = 'editor:';
|
||||
@@ -20,6 +21,7 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
|
||||
class ActiveTabStore {
|
||||
private activeTabId: string = 'vault';
|
||||
private listeners = new Set<Listener>();
|
||||
private syncListeners = new Set<SyncListener>();
|
||||
private notifyRafId: number | null = null;
|
||||
|
||||
getActiveTabId = () => this.activeTabId;
|
||||
@@ -39,6 +41,7 @@ class ActiveTabStore {
|
||||
if (this.activeTabId !== id) {
|
||||
terminalLayoutSuppressStore.begin();
|
||||
this.activeTabId = id;
|
||||
this.syncListeners.forEach((listener) => listener(id));
|
||||
// Coalesce rapid tab switches into one notification per frame and avoid
|
||||
// "setState during render" if called from a render phase.
|
||||
this.scheduleNotify();
|
||||
@@ -57,6 +60,11 @@ class ActiveTabStore {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
subscribeSync = (listener: SyncListener) => {
|
||||
this.syncListeners.add(listener);
|
||||
return () => this.syncListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const activeTabStore = new ActiveTabStore();
|
||||
@@ -109,15 +117,3 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
|
||||
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
// Check if terminal layer should be visible
|
||||
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
|
||||
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
|
||||
const getSnapshot = useCallback(() => {
|
||||
const activeTabId = activeTabStore.getActiveTabId();
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
|
||||
return isTerminalTab || !!draggingSessionId;
|
||||
}, [draggingSessionId]);
|
||||
|
||||
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneStaleSessionPanelViews,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "deleted-session" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next, {
|
||||
"terminal:1": { mode: "draft" },
|
||||
"workspace:2": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:3": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-keep" },
|
||||
"terminal:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneStaleSessionPanelViews(
|
||||
panelViewByScope,
|
||||
new Set(["session-keep"]),
|
||||
);
|
||||
|
||||
assert.equal(next, panelViewByScope);
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
|
||||
@@ -115,6 +115,25 @@ export function setSessionView(
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneStaleSessionPanelViews(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
validSessionIds: Set<string>,
|
||||
): PanelViewByScope {
|
||||
let next = panelViewByScope;
|
||||
|
||||
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
|
||||
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
const updated = setDraftView(next, scopeKey);
|
||||
if (updated !== next) {
|
||||
next = updated;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
|
||||
41
application/state/hostTreeInlineHostEditStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
export type HostTreeInlineHostEdit = {
|
||||
hostId: string;
|
||||
initialName: string;
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class HostTreeInlineHostEditStore {
|
||||
private edit: HostTreeInlineHostEdit | null = null;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getEdit = () => this.edit;
|
||||
|
||||
startEdit = (edit: HostTreeInlineHostEdit) => {
|
||||
this.edit = edit;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
if (!this.edit) return;
|
||||
this.edit = null;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
|
||||
|
||||
export const useHostTreeInlineHostEdit = () => {
|
||||
return useSyncExternalStore(
|
||||
hostTreeInlineHostEditStore.subscribe,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
hostTreeInlineHostEditStore.getEdit,
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
import {
|
||||
resolveTerminalSessionExitIntent,
|
||||
shouldCloseTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
});
|
||||
|
||||
@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
|
||||
return evt.reason === "exited" && evt.exitCode === 0;
|
||||
}
|
||||
|
||||
62
application/state/sessionCapabilitiesStore.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
|
||||
const listenersBySessionId = new Map<string, Set<Listener>>();
|
||||
|
||||
function notifySession(sessionId: string) {
|
||||
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const sessionCapabilitiesStore = {
|
||||
get(sessionId: string): SessionCapabilities | undefined {
|
||||
return capabilitiesBySessionId.get(sessionId);
|
||||
},
|
||||
|
||||
set(sessionId: string, capabilities: SessionCapabilities) {
|
||||
const prev = capabilitiesBySessionId.get(sessionId);
|
||||
if (
|
||||
prev
|
||||
&& prev.targetOs === capabilities.targetOs
|
||||
&& prev.hasTmux === capabilities.hasTmux
|
||||
&& prev.hasDocker === capabilities.hasDocker
|
||||
&& prev.probedAt === capabilities.probedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
capabilitiesBySessionId.set(sessionId, capabilities);
|
||||
notifySession(sessionId);
|
||||
},
|
||||
|
||||
delete(sessionId: string) {
|
||||
if (!capabilitiesBySessionId.delete(sessionId)) return;
|
||||
notifySession(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
},
|
||||
|
||||
/** Drop cached capabilities for sessions that no longer exist. */
|
||||
prune(liveSessionIds: ReadonlySet<string>) {
|
||||
for (const sessionId of capabilitiesBySessionId.keys()) {
|
||||
if (!liveSessionIds.has(sessionId)) {
|
||||
capabilitiesBySessionId.delete(sessionId);
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(sessionId: string, listener: Listener): () => void {
|
||||
let set = listenersBySessionId.get(sessionId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listenersBySessionId.set(sessionId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set?.delete(listener);
|
||||
if (set && set.size === 0) {
|
||||
listenersBySessionId.delete(sessionId);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -71,6 +72,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
}
|
||||
|
||||
@@ -102,6 +104,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
}: UseSettingsIpcSyncParams) {
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -222,6 +225,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
|
||||
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -251,6 +257,7 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
|
||||
@@ -63,6 +63,8 @@ export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
|
||||
export const DEFAULT_SHOW_RECENT_HOSTS = true;
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -129,11 +131,8 @@ export const applyThemeTokens = (
|
||||
accentOverride: string,
|
||||
) => {
|
||||
const root = window.document.documentElement;
|
||||
// If immersive override is active (style tag present), it owns the dark/light class — don't override
|
||||
if (!document.getElementById('netcatty-immersive-override')) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
}
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
root.style.setProperty('--background', tokens.background);
|
||||
root.style.setProperty('--foreground', tokens.foreground);
|
||||
root.style.setProperty('--card', tokens.card);
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -75,6 +77,8 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
shellOnlyTabNumberShortcuts: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -109,6 +113,8 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -130,7 +136,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -139,7 +145,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -153,7 +159,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -163,7 +169,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -371,6 +377,18 @@ export function useSettingsStorageSync({
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
setShowHostTreeSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.shellOnlyTabNumberShortcuts) {
|
||||
setShellOnlyTabNumberShortcutsState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -436,8 +454,10 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setTerminalFontFamilyId,
|
||||
setTerminalFontSize,
|
||||
setTerminalThemeDarkId,
|
||||
|
||||
16
application/state/systemManagerDiagnostics.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export async function writeSystemManagerDiagnostic(
|
||||
message: string,
|
||||
extra?: Record<string, unknown>,
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.logDiagnostic?.({
|
||||
source: 'system-manager',
|
||||
message,
|
||||
extra,
|
||||
});
|
||||
} catch {
|
||||
// Diagnostics must never block the user action being diagnosed.
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ function createTerminalSessionClone(
|
||||
localShellArgs: session.localShellArgs,
|
||||
localShellName: session.localShellName,
|
||||
localShellIcon: session.localShellIcon,
|
||||
fontSize: session.fontSize,
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
};
|
||||
|
||||
|
||||
5
application/state/terminalHostTreeAnimation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
|
||||
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
|
||||
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;
|
||||
46
application/state/terminalHostTreeStore.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
|
||||
clampTerminalHostTreeWidth,
|
||||
terminalHostTreeStore,
|
||||
} = await import('./terminalHostTreeStore.ts');
|
||||
|
||||
test('closing host tree state does not mutate layout width by itself', () => {
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
terminalHostTreeStore.setLayoutWidth(240);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('opening host tree state does not jump the layout width', () => {
|
||||
storage.set('netcatty_terminal_host_tree_width_v1', '300');
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
terminalHostTreeStore.setIsOpen(false);
|
||||
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
|
||||
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
|
||||
terminalHostTreeStore.setLayoutWidth(0);
|
||||
});
|
||||
|
||||
test('host tree restored layout width is clamped', () => {
|
||||
assert.equal(clampTerminalHostTreeWidth(80), 160);
|
||||
assert.equal(clampTerminalHostTreeWidth(999), 360);
|
||||
assert.equal(clampTerminalHostTreeWidth(0), 160);
|
||||
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
|
||||
});
|
||||
@@ -5,6 +5,17 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
|
||||
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
|
||||
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
|
||||
|
||||
export function clampTerminalHostTreeWidth(width: number): number {
|
||||
return Math.max(
|
||||
TERMINAL_HOST_TREE_MIN_WIDTH,
|
||||
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
|
||||
);
|
||||
}
|
||||
|
||||
function readIsOpen(): boolean {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
|
||||
// Legacy key stores "collapsed"; open is the inverse.
|
||||
@@ -26,9 +37,6 @@ class TerminalHostTreeStore {
|
||||
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',
|
||||
@@ -37,7 +45,7 @@ class TerminalHostTreeStore {
|
||||
};
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, width);
|
||||
const next = Math.max(0, Math.round(width));
|
||||
if (this.layoutWidth === next) return;
|
||||
this.layoutWidth = next;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
|
||||
76
application/state/themeTransition.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
THEME_TRANSITION_ATTR,
|
||||
THEME_TRANSITION_MS,
|
||||
runThemeTransition,
|
||||
} from "./themeTransition.ts";
|
||||
|
||||
function createRoot() {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
attributes,
|
||||
ownerDocument: { startViewTransition: undefined },
|
||||
setAttribute: (name: string, value: string) => attributes.set(name, value),
|
||||
removeAttribute: (name: string) => attributes.delete(name),
|
||||
getAttribute: (name: string) => attributes.get(name) ?? null,
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
});
|
||||
|
||||
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
|
||||
const root = createRoot();
|
||||
let count = 0;
|
||||
|
||||
runThemeTransition(() => {
|
||||
count += 1;
|
||||
}, root);
|
||||
runThemeTransition(() => {
|
||||
count += 2;
|
||||
}, root);
|
||||
|
||||
assert.equal(count, 3);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
|
||||
});
|
||||
|
||||
test("runThemeTransition uses view transition API when available", async () => {
|
||||
const root = createRoot();
|
||||
let applied = false;
|
||||
let finished = false;
|
||||
const doc = {
|
||||
startViewTransition: (callback: () => void) => {
|
||||
callback();
|
||||
return {
|
||||
finished: Promise.resolve().then(() => {
|
||||
finished = true;
|
||||
}),
|
||||
skipTransition: () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
|
||||
|
||||
runThemeTransition(() => {
|
||||
applied = true;
|
||||
}, root);
|
||||
|
||||
assert.equal(applied, true);
|
||||
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
61
application/state/themeTransition.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
|
||||
|
||||
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
|
||||
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void | Promise<void>) => {
|
||||
finished: Promise<void>;
|
||||
skipTransition: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
let cancelThemeTransitionReset: (() => void) | null = null;
|
||||
|
||||
export function runThemeTransition(
|
||||
apply: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): void {
|
||||
cancelThemeTransitionReset?.();
|
||||
|
||||
const cleanup = () => {
|
||||
root.removeAttribute(THEME_TRANSITION_ATTR);
|
||||
cancelThemeTransitionReset = null;
|
||||
};
|
||||
|
||||
const doc = root.ownerDocument as DocumentWithViewTransition | null;
|
||||
const startViewTransition = doc?.startViewTransition?.bind(doc);
|
||||
|
||||
if (startViewTransition) {
|
||||
let transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>> | null = null;
|
||||
try {
|
||||
transition = startViewTransition(() => {
|
||||
apply();
|
||||
});
|
||||
} catch {
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
cancelThemeTransitionReset = () => {
|
||||
transition?.skipTransition();
|
||||
cleanup();
|
||||
};
|
||||
void transition.finished.finally(cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
|
||||
apply();
|
||||
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
|
||||
cancelThemeTransitionReset = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
|
||||
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
|
||||
import type {
|
||||
AIDraft,
|
||||
AISession,
|
||||
@@ -35,6 +38,8 @@ import {
|
||||
activateDraftView,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
pruneStaleSessionPanelViews,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
@@ -158,6 +163,11 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
// ── Quick Messages (slash prompts) ──
|
||||
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
|
||||
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
@@ -187,12 +197,22 @@ export function useAIState() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
if (changed) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const next = pruneStaleSessionPanelViews(prev, validSessionIds);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
@@ -263,6 +283,16 @@ export function useAIState() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
|
||||
setQuickMessagesRaw((prev) => {
|
||||
const nextRaw = typeof value === 'function' ? value(prev) : value;
|
||||
const next = sanitizeQuickMessages(nextRaw);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Persist helpers ──
|
||||
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
|
||||
setProvidersRaw(prev => {
|
||||
@@ -454,6 +484,11 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
case STORAGE_KEY_AI_QUICK_MESSAGES: {
|
||||
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
setQuickMessagesRaw(sanitizeQuickMessages(messages));
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
|
||||
@@ -593,6 +628,19 @@ export function useAIState() {
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const currentPanelView = prev[scopeKey];
|
||||
if (currentPanelView?.mode !== 'session' || currentPanelView.sessionId !== sessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = setDraftView(prev, scopeKey);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [persistSessions]);
|
||||
|
||||
@@ -974,6 +1022,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
@@ -1029,6 +1079,8 @@ export function useAIState() {
|
||||
setAgentProvider,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
setQuickMessages,
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
|
||||
49
application/state/useActiveChromeTheme.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
scheduleChromeLayoutAnimation,
|
||||
} from "./useActiveChromeTheme.ts";
|
||||
|
||||
function createRafRoot() {
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
const view = {
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
const id = nextId++;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
cancelAnimationFrame: (id: number) => {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
ownerDocument: { defaultView: view },
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
const flushFrame = () => {
|
||||
const [id, callback] = callbacks.entries().next().value ?? [];
|
||||
if (!id || !callback) return false;
|
||||
callbacks.delete(id);
|
||||
callback(0);
|
||||
return true;
|
||||
};
|
||||
|
||||
return { root, flushFrame };
|
||||
}
|
||||
|
||||
test("chrome layout animations wait until theme settle frames complete", () => {
|
||||
const { root, flushFrame } = createRafRoot();
|
||||
let ran = false;
|
||||
|
||||
const cancel = scheduleChromeLayoutAnimation(() => {
|
||||
ran = true;
|
||||
}, root);
|
||||
|
||||
while (!ran && flushFrame()) {
|
||||
// Drain scheduled animation frames.
|
||||
}
|
||||
assert.equal(ran, true);
|
||||
cancel();
|
||||
});
|
||||
258
application/state/useActiveChromeTheme.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { TerminalTheme } from "../../domain/models";
|
||||
import {
|
||||
applyTopTabsChromeThemeVars,
|
||||
clearTopTabsChromeThemeVars,
|
||||
} from "../app/topTabsChromeTheme";
|
||||
import { runThemeTransition } from "./themeTransition";
|
||||
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const nextLightness = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(nextLightness * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const nextSaturation = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(nextSaturation * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
const CSS_VARS = [
|
||||
"background",
|
||||
"foreground",
|
||||
"card",
|
||||
"card-foreground",
|
||||
"popover",
|
||||
"popover-foreground",
|
||||
"primary",
|
||||
"primary-foreground",
|
||||
"secondary",
|
||||
"secondary-foreground",
|
||||
"muted",
|
||||
"muted-foreground",
|
||||
"accent",
|
||||
"accent-foreground",
|
||||
"destructive",
|
||||
"destructive-foreground",
|
||||
"border",
|
||||
"input",
|
||||
"ring",
|
||||
] as const;
|
||||
|
||||
function buildChromeCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === "dark";
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
|
||||
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg,
|
||||
card, fg,
|
||||
cursor, primaryFg,
|
||||
secondary, fg,
|
||||
muted, mutedFg,
|
||||
cursor, primaryFg,
|
||||
"0 70% 50%", "0 0% 100%",
|
||||
border, border, cursor,
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, index) => `--${name}: ${values[index]} !important`).join("; ");
|
||||
return [
|
||||
`:root { ${rules}; }`,
|
||||
`:root[data-active-chrome-theme] [data-agent-badge] { border-color: hsl(var(--primary) / 0.2) !important; background-color: hsl(var(--primary) / 0.1) !important; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
export function themeFingerprint(theme: TerminalTheme): string {
|
||||
return `${theme.id}\0${theme.type}\0${theme.colors.background}\0${theme.colors.foreground}\0${theme.colors.cursor}`;
|
||||
}
|
||||
|
||||
function getAppliedChromeFingerprint(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
return document.documentElement.dataset.activeChromeTheme ?? null;
|
||||
}
|
||||
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildChromeCss(theme));
|
||||
}
|
||||
|
||||
function getChromeCss(theme: TerminalTheme): string {
|
||||
const fingerprint = themeFingerprint(theme);
|
||||
let css = cssCache.get(fingerprint);
|
||||
if (!css) {
|
||||
css = buildChromeCss(theme);
|
||||
cssCache.set(fingerprint, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
const STYLE_ID = "netcatty-active-chrome-theme";
|
||||
/** Double-rAF window used to let layout settle after a paint. */
|
||||
export const INSTANT_THEME_SWITCH_SETTLE_FRAMES = 2;
|
||||
|
||||
function getAnimationView(root: HTMLElement) {
|
||||
return root.ownerDocument?.defaultView ?? globalThis.window;
|
||||
}
|
||||
|
||||
/** Run after instant theme switch finishes suppressing CSS transitions. */
|
||||
export function scheduleAfterInstantThemeSwitch(
|
||||
callback: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): () => void {
|
||||
const view = getAnimationView(root);
|
||||
const requestFrame = view?.requestAnimationFrame?.bind(view)
|
||||
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
|
||||
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
|
||||
?? ((id: number) => { globalThis.clearTimeout(id); });
|
||||
|
||||
const frameIds: number[] = [];
|
||||
const scheduleFrames = (remaining: number) => {
|
||||
const frameId = requestFrame(() => {
|
||||
const index = frameIds.indexOf(frameId);
|
||||
if (index >= 0) frameIds.splice(index, 1);
|
||||
if (remaining <= 1) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
scheduleFrames(remaining - 1);
|
||||
});
|
||||
frameIds.push(frameId);
|
||||
};
|
||||
|
||||
scheduleFrames(INSTANT_THEME_SWITCH_SETTLE_FRAMES);
|
||||
return () => {
|
||||
for (const frameId of frameIds) cancelFrame(frameId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one frame after instant theme switch settles so layout transitions can
|
||||
* start from the pre-animation state without `transition: none` on :root.
|
||||
*/
|
||||
export function scheduleChromeLayoutAnimation(
|
||||
callback: () => void,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): () => void {
|
||||
let layoutFrameId = 0;
|
||||
const cancelSettle = scheduleAfterInstantThemeSwitch(() => {
|
||||
const view = getAnimationView(root);
|
||||
const requestFrame = view?.requestAnimationFrame?.bind(view)
|
||||
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
|
||||
layoutFrameId = requestFrame(() => callback());
|
||||
}, root);
|
||||
return () => {
|
||||
cancelSettle();
|
||||
const view = getAnimationView(root);
|
||||
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
|
||||
?? ((id: number) => { globalThis.clearTimeout(id); });
|
||||
if (layoutFrameId) cancelFrame(layoutFrameId);
|
||||
};
|
||||
}
|
||||
|
||||
function removeActiveChromeTheme() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.activeChromeTheme;
|
||||
}
|
||||
|
||||
function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
runThemeTransition(() => {
|
||||
const root = document.documentElement;
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(targetClass);
|
||||
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = getChromeCss(theme);
|
||||
root.dataset.activeChromeTheme = themeFingerprint(theme);
|
||||
netcattyBridge.get()?.setTheme?.(targetClass);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
applyTopTabsChromeThemeVars(theme);
|
||||
});
|
||||
}
|
||||
|
||||
export function syncActiveChromeTheme(
|
||||
activeTheme: TerminalTheme | null,
|
||||
applyAppTheme: () => void,
|
||||
): void {
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint) return;
|
||||
|
||||
if (activeTheme) {
|
||||
applyActiveChromeTheme(activeTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTopTabsChromeThemeVars();
|
||||
runThemeTransition(() => {
|
||||
removeActiveChromeTheme();
|
||||
applyAppTheme();
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveChromeTheme({
|
||||
activeTheme,
|
||||
applyAppTheme,
|
||||
}: {
|
||||
activeTheme: TerminalTheme | null;
|
||||
applyAppTheme: () => void;
|
||||
}) {
|
||||
const applyAppThemeRef = useRef(applyAppTheme);
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncActiveChromeTheme(activeTheme, applyAppTheme);
|
||||
}, [activeTheme, applyAppTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
removeActiveChromeTheme();
|
||||
clearTopTabsChromeThemeVars();
|
||||
applyAppThemeRef.current();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/
|
||||
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
|
||||
|
||||
interface NetcattyBridge {
|
||||
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
|
||||
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
|
||||
}
|
||||
|
||||
function getBridge(): NetcattyBridge | undefined {
|
||||
@@ -19,20 +19,27 @@ export function useAgentDiscovery(
|
||||
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
|
||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||
|
||||
const discover = useCallback(async () => {
|
||||
const cursorApiKeyPresent = externalAgents.some(
|
||||
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
|
||||
);
|
||||
|
||||
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
setIsDiscovering(true);
|
||||
try {
|
||||
const agents = await bridge.aiDiscoverAgents();
|
||||
const agents = await bridge.aiDiscoverAgents({
|
||||
...discoverOptions,
|
||||
apiKeyPresent: cursorApiKeyPresent,
|
||||
});
|
||||
setDiscoveredAgents(agents);
|
||||
} catch (err) {
|
||||
console.error('Agent discovery failed:', err);
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
}, [cursorApiKeyPresent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -128,7 +135,7 @@ export function useAgentDiscovery(
|
||||
discoveredAgents,
|
||||
unconfiguredAgents,
|
||||
isDiscovering,
|
||||
rediscover: discover,
|
||||
rediscover: () => discover({ refreshShellEnv: true }),
|
||||
enableAgent,
|
||||
};
|
||||
}
|
||||
|
||||
34
application/state/useComposeBarHeight.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredNumber } from './useStoredNumber';
|
||||
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 72;
|
||||
export const COMPOSE_BAR_HEIGHT_MAX = 360;
|
||||
|
||||
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
|
||||
|
||||
function clampHeight(height: number): number {
|
||||
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
|
||||
}
|
||||
|
||||
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
|
||||
export function useComposeBarHeight() {
|
||||
const [height, setHeight, persist] = useStoredNumber(
|
||||
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
|
||||
COMPOSE_BAR_HEIGHT_DEFAULT,
|
||||
HEIGHT_CLAMP,
|
||||
);
|
||||
|
||||
const setClampedHeight = useCallback(
|
||||
(next: number | ((prev: number) => number)) => {
|
||||
setHeight((prev) => {
|
||||
const raw = typeof next === 'function' ? next(prev) : next;
|
||||
return clampHeight(raw);
|
||||
});
|
||||
},
|
||||
[setHeight],
|
||||
);
|
||||
|
||||
return [height, setClampedHeight, persist] as const;
|
||||
}
|
||||
106
application/state/useComposeBarPinnedSnippets.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
interface PinnedState {
|
||||
pinnedIds: string[];
|
||||
/** True when the user has never saved pins (localStorage key absent). */
|
||||
neverSaved: boolean;
|
||||
}
|
||||
|
||||
function readPinnedState(): PinnedState {
|
||||
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
|
||||
if (stored === null) {
|
||||
return { pinnedIds: [], neverSaved: true };
|
||||
}
|
||||
return {
|
||||
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
|
||||
neverSaved: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
|
||||
if (!snippetIdKey) return null;
|
||||
const ids = snippetIdKey.split('\0').filter(Boolean);
|
||||
if (ids.length === 0) return null;
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted snippet IDs shown on the terminal compose bar quick strip.
|
||||
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
|
||||
* On first run, `defaultSeedIds` are written once when pins were never saved.
|
||||
*/
|
||||
export function useComposeBarPinnedSnippets(
|
||||
snippetIdKey?: string,
|
||||
defaultSeedIds?: readonly string[],
|
||||
) {
|
||||
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
|
||||
const skipNextPersistRef = useRef(true);
|
||||
const needsSeedRef = useRef(neverSaved);
|
||||
|
||||
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
|
||||
setPinnedState((prev) => {
|
||||
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
|
||||
return { pinnedIds: nextIds, neverSaved: false };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextPersistRef.current) {
|
||||
skipNextPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
|
||||
}, [pinnedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSeedRef.current) return;
|
||||
|
||||
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
|
||||
if (seed.length === 0) return;
|
||||
|
||||
const applySeed = () => {
|
||||
if (!needsSeedRef.current) return;
|
||||
needsSeedRef.current = false;
|
||||
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
|
||||
};
|
||||
|
||||
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
|
||||
if (!isBuiltinSeed) {
|
||||
applySeed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Brief delay so vault snippets can load before falling back to built-ins.
|
||||
const timer = setTimeout(applySeed, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [defaultSeedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = parseSnippetIdKey(snippetIdKey);
|
||||
if (!valid) return;
|
||||
setPinnedIds((prev) => {
|
||||
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, [snippetIdKey, setPinnedIds]);
|
||||
|
||||
const pin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const unpin = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => prev.filter((x) => x !== id));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setPinnedIds((prev) => (
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
));
|
||||
}, [setPinnedIds]);
|
||||
|
||||
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
|
||||
|
||||
return { pinnedIds, pin, unpin, toggle, isPinned };
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
|
||||
* - Custom/unknown themes are computed lazily and cached
|
||||
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
|
||||
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||
case g: h = ((b - r) / d + 2) / 6; break;
|
||||
case b: h = ((r - g) / d + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustLightness(hsl: string, delta: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
|
||||
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
|
||||
}
|
||||
|
||||
function adjustSaturation(hsl: string, factor: number): string {
|
||||
const parts = hsl.split(/\s+/);
|
||||
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
|
||||
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the CSS rule string from a TerminalTheme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSS_VARS = [
|
||||
'background', 'foreground', 'card', 'card-foreground',
|
||||
'popover', 'popover-foreground', 'primary', 'primary-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
|
||||
'border', 'input', 'ring',
|
||||
] as const;
|
||||
|
||||
function buildImmersiveCss(theme: TerminalTheme): string {
|
||||
const bg = hexToHsl(theme.colors.background);
|
||||
const fg = hexToHsl(theme.colors.foreground);
|
||||
const cursor = hexToHsl(theme.colors.cursor);
|
||||
const isDark = theme.type === 'dark';
|
||||
|
||||
const card = adjustLightness(bg, isDark ? 4 : -3);
|
||||
const secondary = adjustLightness(bg, isDark ? 6 : -5);
|
||||
const muted = adjustLightness(bg, isDark ? 10 : -8);
|
||||
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
|
||||
const border = adjustLightness(bg, isDark ? 12 : -10);
|
||||
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
|
||||
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
|
||||
|
||||
const values = [
|
||||
bg, fg, card, fg, // background, foreground, card, card-foreground
|
||||
card, fg, // popover, popover-foreground
|
||||
cursor, primaryFg, // primary, primary-foreground
|
||||
secondary, fg, // secondary, secondary-foreground
|
||||
muted, mutedFg, // muted, muted-foreground
|
||||
cursor, primaryFg, // accent, accent-foreground
|
||||
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
|
||||
border, border, cursor, // border, input, ring
|
||||
];
|
||||
|
||||
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
|
||||
return `:root { ${rules}; }`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cssCache = new Map<string, string>();
|
||||
|
||||
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
|
||||
function themeFingerprint(t: TerminalTheme): string {
|
||||
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
|
||||
}
|
||||
|
||||
// Pre-compute built-in themes
|
||||
for (const theme of TERMINAL_THEMES) {
|
||||
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
|
||||
}
|
||||
|
||||
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
|
||||
function getImmersiveCss(theme: TerminalTheme): string {
|
||||
const fp = themeFingerprint(theme);
|
||||
let css = cssCache.get(fp);
|
||||
if (!css) {
|
||||
css = buildImmersiveCss(theme);
|
||||
cssCache.set(fp, css);
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style tag management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STYLE_ID = 'netcatty-immersive-override';
|
||||
|
||||
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
|
||||
const root = document.documentElement;
|
||||
const targetClass = isDark ? 'dark' : 'light';
|
||||
if (!root.classList.contains(targetClass)) {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(targetClass);
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
// Sync native Electron window chrome
|
||||
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
|
||||
netcattyBridge.get()?.setBackgroundColor?.(bg);
|
||||
}
|
||||
|
||||
function removeImmersiveStyle() {
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
delete document.documentElement.dataset.immersiveTheme;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useImmersiveMode({
|
||||
activeTabId,
|
||||
activeTerminalTheme,
|
||||
restoreOriginalTheme,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
activeTerminalTheme: TerminalTheme | null;
|
||||
restoreOriginalTheme: () => void;
|
||||
}) {
|
||||
const overrideActiveRef = useRef(false);
|
||||
const appliedFpRef = useRef<string | null>(null);
|
||||
const restoreRef = useRef(restoreOriginalTheme);
|
||||
restoreRef.current = restoreOriginalTheme;
|
||||
|
||||
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
|
||||
|
||||
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
|
||||
useLayoutEffect(() => {
|
||||
if (isTerminalTab && activeTerminalTheme) {
|
||||
const fp = themeFingerprint(activeTerminalTheme);
|
||||
if (appliedFpRef.current === fp) return;
|
||||
overrideActiveRef.current = true;
|
||||
appliedFpRef.current = fp;
|
||||
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
|
||||
document.documentElement.dataset.immersiveTheme = fp;
|
||||
}
|
||||
}, [isTerminalTab, activeTerminalTheme]);
|
||||
|
||||
// RESTORE: useEffect — runs after paint, with fade overlay
|
||||
useEffect(() => {
|
||||
if (isTerminalTab && activeTerminalTheme) return;
|
||||
if (!overrideActiveRef.current) return;
|
||||
overrideActiveRef.current = false;
|
||||
appliedFpRef.current = null;
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'immersive-fade-overlay';
|
||||
overlay.style.backgroundColor = `hsl(${bg})`;
|
||||
document.body.appendChild(overlay);
|
||||
removeImmersiveStyle();
|
||||
restoreOriginalTheme();
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('fade-out');
|
||||
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
|
||||
});
|
||||
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
|
||||
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
|
||||
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeImmersiveStyle();
|
||||
appliedFpRef.current = null;
|
||||
if (overrideActiveRef.current) {
|
||||
overrideActiveRef.current = false;
|
||||
restoreRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -30,7 +31,7 @@ let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
export type SortMode = "az" | "za" | "newest" | "oldest";
|
||||
export type SortMode = "manual" | "az" | "za" | "newest" | "oldest";
|
||||
|
||||
export interface UsePortForwardingStateResult {
|
||||
rules: PortForwardingRule[];
|
||||
@@ -52,6 +53,7 @@ export interface UsePortForwardingStateResult {
|
||||
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
||||
deleteRule: (id: string) => void;
|
||||
duplicateRule: (id: string) => void;
|
||||
reorderRule: (sourceId: string, targetId: string, position: VaultOrderPosition) => void;
|
||||
importRules: (rules: PortForwardingRule[]) => void;
|
||||
|
||||
setRuleStatus: (
|
||||
@@ -90,9 +92,9 @@ const notifyListeners = () => {
|
||||
};
|
||||
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = newRules;
|
||||
globalRules = normalizeVaultOrder(newRules);
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
|
||||
@@ -136,7 +138,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("newest");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const [search, setSearch] = useState("");
|
||||
const [preferFormMode, setPreferFormModeState] = useState<boolean>(() => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
@@ -249,6 +251,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
status: "inactive",
|
||||
order: getNextVaultOrder(globalRules),
|
||||
};
|
||||
const updated = [...globalRules, newRule];
|
||||
setGlobalRules(updated);
|
||||
@@ -294,6 +297,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
order: getNextVaultOrder(globalRules),
|
||||
};
|
||||
const updated = [...globalRules, copy];
|
||||
setGlobalRules(updated);
|
||||
@@ -302,6 +306,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
[],
|
||||
);
|
||||
|
||||
const reorderRule = useCallback(
|
||||
(sourceId: string, targetId: string, position: VaultOrderPosition) => {
|
||||
setGlobalRules(reorderVaultItems(globalRules, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
|
||||
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
|
||||
@@ -444,6 +456,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
case "oldest":
|
||||
result.sort((a, b) => a.createdAt - b.createdAt);
|
||||
break;
|
||||
case "manual":
|
||||
result = sortByVaultOrder(result);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -469,6 +484,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
reorderRule,
|
||||
importRules,
|
||||
|
||||
setRuleStatus,
|
||||
|
||||
174
application/state/useRemoteHistoryState.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
mergeRemoteHistory,
|
||||
parseBashHistory,
|
||||
parseFishHistory,
|
||||
parseZshHistory,
|
||||
} from '../../domain/remoteHistory';
|
||||
import type { RemoteHistoryEntry } from '../../domain/models';
|
||||
|
||||
export interface RemoteHistoryHostState {
|
||||
entries: RemoteHistoryEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
fetchedAt: number | null;
|
||||
}
|
||||
|
||||
const EMPTY_STATE: RemoteHistoryHostState = {
|
||||
entries: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchedAt: null,
|
||||
};
|
||||
|
||||
const PENDING_RETRY_MS = 1500;
|
||||
const PENDING_MAX_RETRIES = 12;
|
||||
|
||||
export interface UseRemoteHistoryState {
|
||||
getState: (
|
||||
hostId: string | null | undefined,
|
||||
sessionId?: string | null,
|
||||
) => RemoteHistoryHostState;
|
||||
fetch: (sessionId: string, hostId: string) => Promise<void>;
|
||||
clear: (hostId: string, sessionId?: string | null) => void;
|
||||
}
|
||||
|
||||
function cacheKey(hostId: string, sessionId: string): string {
|
||||
return `${hostId}\0${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns per-session remote shell history state. Fetches the remote host's shell
|
||||
* history via the SSH bridge — which detects the login shell and returns only
|
||||
* the matching file(s) — parses and de-dupes them, and keeps an in-memory
|
||||
* cache keyed by (hostId, sessionId). The cache is intentionally not persisted
|
||||
* — history files can contain sensitive content.
|
||||
*/
|
||||
export function useRemoteHistoryState(): UseRemoteHistoryState {
|
||||
const [byKey, setByKey] = useState<Record<string, RemoteHistoryHostState>>({});
|
||||
const requestIdByKey = useRef<Record<string, number>>({});
|
||||
|
||||
const getState = useCallback(
|
||||
(
|
||||
hostId: string | null | undefined,
|
||||
sessionId?: string | null,
|
||||
): RemoteHistoryHostState => {
|
||||
if (!hostId || !sessionId) return EMPTY_STATE;
|
||||
return byKey[cacheKey(hostId, sessionId)] ?? EMPTY_STATE;
|
||||
},
|
||||
[byKey],
|
||||
);
|
||||
|
||||
const fetch = useCallback(async (sessionId: string, hostId: string) => {
|
||||
if (!sessionId || !hostId) return;
|
||||
const key = cacheKey(hostId, sessionId);
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readRemoteHistory) {
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: 'Remote history is not available in this build.',
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = (requestIdByKey.current[key] ?? 0) + 1;
|
||||
requestIdByKey.current[key] = reqId;
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: true,
|
||||
error: null,
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
const isStale = () => requestIdByKey.current[key] !== reqId;
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt <= PENDING_MAX_RETRIES; attempt += 1) {
|
||||
const result = await bridge.readRemoteHistory(sessionId, 1000);
|
||||
if (isStale()) return;
|
||||
|
||||
if (!result?.success) {
|
||||
if (result?.pending && attempt < PENDING_MAX_RETRIES) {
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, PENDING_RETRY_MS);
|
||||
});
|
||||
if (isStale()) return;
|
||||
continue;
|
||||
}
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: result?.pending
|
||||
? 'Remote history is not ready yet. Try again shortly.'
|
||||
: (result?.error || 'Failed to read remote history'),
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const lists: RemoteHistoryEntry[][] = [];
|
||||
if (result.shell === 'bash') {
|
||||
lists.push(parseBashHistory(result.bash ?? ''));
|
||||
} else if (result.shell === 'zsh') {
|
||||
lists.push(parseZshHistory(result.zsh ?? ''));
|
||||
} else if (result.shell === 'fish') {
|
||||
lists.push(parseFishHistory(result.fish ?? ''));
|
||||
} else {
|
||||
lists.push(parseBashHistory(result.bash ?? ''));
|
||||
lists.push(parseZshHistory(result.zsh ?? ''));
|
||||
lists.push(parseFishHistory(result.fish ?? ''));
|
||||
}
|
||||
const merged = mergeRemoteHistory(lists);
|
||||
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: merged,
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale()) return;
|
||||
setByKey((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
entries: prev[key]?.entries ?? [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
fetchedAt: prev[key]?.fetchedAt ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((hostId: string, sessionId?: string | null) => {
|
||||
const key = sessionId ? cacheKey(hostId, sessionId) : hostId;
|
||||
requestIdByKey.current[key] = (requestIdByKey.current[key] ?? 0) + 1;
|
||||
setByKey((prev) => {
|
||||
if (!(key in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { getState, fetch, clear };
|
||||
}
|
||||
@@ -16,6 +16,8 @@ SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
@@ -71,6 +73,18 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
|
||||
setSessions(prev => prev.map(s => (
|
||||
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
|
||||
)));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
@@ -857,31 +871,33 @@ export const useSessionState = () => {
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
|
||||
const orderedTabs = useMemo(() => {
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
|
||||
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
return [...orderedIds, ...newIds];
|
||||
}, [orphanSessions, workspaces, logViews, tabOrder]);
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
], [orphanSessions, workspaces, logViews]);
|
||||
|
||||
const reorderTabs = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' = 'before') => {
|
||||
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
return buildOrderedWorkTabIds(tabOrder, allTabIds);
|
||||
}, [baseWorkTabIds, tabOrder]);
|
||||
|
||||
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
|
||||
const orderedTabs = useMemo(
|
||||
() => getOrderedWorkTabs(),
|
||||
[getOrderedWorkTabs],
|
||||
);
|
||||
|
||||
const reorderTabs = useCallback((
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: 'before' | 'after' = 'before',
|
||||
additionalTabIds: readonly string[] = [],
|
||||
) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Get all current tab IDs (orphan sessions + workspaces + log views)
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
|
||||
// Build current effective order: existing order + new tabs at end
|
||||
@@ -913,7 +929,7 @@ export const useSessionState = () => {
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews]);
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -940,6 +956,8 @@ export const useSessionState = () => {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
updateSessionFontSize,
|
||||
clearSessionFontSizeOverride,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
@@ -958,6 +976,7 @@ export const useSessionState = () => {
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
// Log views
|
||||
logViews,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition } from './themeTransition';
|
||||
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
@@ -43,6 +45,8 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -83,6 +87,8 @@ import {
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -104,6 +110,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
|
||||
import { useSettingsIpcSync } from './settingsIpcSync';
|
||||
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
|
||||
import { useSystemSettingsEffects } from './systemSettingsEffects';
|
||||
import { applyCustomCssToDocument } from '../../lib/customCss';
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const initialCustomKeyBindingsRecord =
|
||||
@@ -229,6 +236,14 @@ export const useSettingsState = () => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
});
|
||||
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
|
||||
});
|
||||
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -441,7 +456,9 @@ export const useSettingsState = () => {
|
||||
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
runThemeTransition(() => {
|
||||
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
|
||||
});
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -523,6 +540,10 @@ export const useSettingsState = () => {
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -534,7 +555,12 @@ export const useSettingsState = () => {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
if (persistMountedRef.current) {
|
||||
runThemeTransition(apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -608,6 +634,7 @@ export const useSettingsState = () => {
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
@@ -634,7 +661,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -643,7 +670,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -750,16 +777,23 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
|
||||
setShowHostTreeSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
|
||||
setShellOnlyTabNumberShortcutsState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'netcatty-custom-css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
applyCustomCssToDocument(customCSS);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
@@ -923,8 +957,7 @@ export const useSettingsState = () => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
|
||||
const reapplyCurrentTheme = useCallback(() => {
|
||||
const applyAppTheme = useCallback(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
@@ -994,6 +1027,10 @@ export const useSettingsState = () => {
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
setShellOnlyTabNumberShortcuts,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1027,7 +1064,7 @@ export const useSettingsState = () => {
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
@@ -1038,7 +1075,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
199
application/state/useSystemManagerBackend.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useSystemManagerBackend() {
|
||||
const probeSystemCapabilities = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.probeSystemCapabilities) {
|
||||
return { success: false as const, error: 'probeSystemCapabilities unavailable' };
|
||||
}
|
||||
return bridge.probeSystemCapabilities(sessionId);
|
||||
}, []);
|
||||
|
||||
const listSystemProcesses = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listSystemProcesses) {
|
||||
return { success: false as const, error: 'listSystemProcesses unavailable' };
|
||||
}
|
||||
return bridge.listSystemProcesses(sessionId);
|
||||
}, []);
|
||||
|
||||
const signalSystemProcess = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
signal?: string;
|
||||
nice?: number;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.signalSystemProcess) {
|
||||
return { success: false as const, error: 'signalSystemProcess unavailable' };
|
||||
}
|
||||
return bridge.signalSystemProcess(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxSessions = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxSessions) {
|
||||
return { success: false as const, error: 'listTmuxSessions unavailable' };
|
||||
}
|
||||
return bridge.listTmuxSessions(sessionId);
|
||||
}, []);
|
||||
|
||||
const createTmuxSession = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
name: string;
|
||||
command?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createTmuxSession) {
|
||||
return { success: false as const, error: 'createTmuxSession unavailable' };
|
||||
}
|
||||
return bridge.createTmuxSession(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxWindows = useCallback(async (options: { sessionId: string; sessionName: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxWindows) {
|
||||
return { success: false as const, error: 'listTmuxWindows unavailable' };
|
||||
}
|
||||
return bridge.listTmuxWindows(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxPanes = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
windowIndex: number;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxPanes) {
|
||||
return { success: false as const, error: 'listTmuxPanes unavailable' };
|
||||
}
|
||||
return bridge.listTmuxPanes(options);
|
||||
}, []);
|
||||
|
||||
const listTmuxClients = useCallback(async (options: { sessionId: string; sessionName?: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listTmuxClients) {
|
||||
return { success: false as const, error: 'listTmuxClients unavailable' };
|
||||
}
|
||||
return bridge.listTmuxClients(options);
|
||||
}, []);
|
||||
|
||||
const tmuxAction = useCallback(async (options: { sessionId: string } & TmuxManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.tmuxAction) {
|
||||
return { success: false as const, error: 'tmuxAction unavailable' };
|
||||
}
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
}
|
||||
return bridge.getDockerStats(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
}
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
}
|
||||
return bridge.dockerImageInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
return { success: false as const, error: 'dockerAction unavailable' };
|
||||
}
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
}
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openTerminalPopup) {
|
||||
return { success: false as const, error: 'openTerminalPopup unavailable' };
|
||||
}
|
||||
return bridge.openTerminalPopup(payload);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
probeSystemCapabilities,
|
||||
listSystemProcesses,
|
||||
signalSystemProcess,
|
||||
listTmuxSessions,
|
||||
createTmuxSession,
|
||||
listTmuxWindows,
|
||||
listTmuxPanes,
|
||||
listTmuxClients,
|
||||
tmuxAction,
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
listSystemProcesses,
|
||||
signalSystemProcess,
|
||||
listTmuxSessions,
|
||||
createTmuxSession,
|
||||
listTmuxWindows,
|
||||
listTmuxPanes,
|
||||
listTmuxClients,
|
||||
tmuxAction,
|
||||
listDockerContainers,
|
||||
listDockerImages,
|
||||
getDockerStats,
|
||||
dockerInspect,
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -132,6 +132,11 @@ export const useTerminalBackend = () => {
|
||||
return bridge?.onConnectionReuseFallback?.(cb);
|
||||
}, []);
|
||||
|
||||
const onWindowFullScreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowFullScreenChanged?.(cb);
|
||||
}, []);
|
||||
|
||||
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onHostKeyVerification?.(cb);
|
||||
@@ -170,10 +175,36 @@ export const useTerminalBackend = () => {
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string) => {
|
||||
const serialYmodemAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.sendSerialYmodem;
|
||||
}, []);
|
||||
|
||||
const selectFileAvailable = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return !!bridge?.selectFile;
|
||||
}, []);
|
||||
|
||||
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
|
||||
return bridge.sendSerialYmodem(sessionId, filePath);
|
||||
}, []);
|
||||
|
||||
const selectFile = useCallback(async (
|
||||
title?: string,
|
||||
defaultPath?: string,
|
||||
filters?: Array<{ name: string; extensions: string[] }>,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.selectFile) return null;
|
||||
return bridge.selectFile(title, defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
return bridge.getSessionPwd(sessionId);
|
||||
return bridge.getSessionPwd(sessionId, options);
|
||||
}, []);
|
||||
|
||||
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
|
||||
@@ -224,6 +255,10 @@ export const useTerminalBackend = () => {
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
selectFile,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -240,6 +275,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
@@ -260,6 +296,10 @@ export const useTerminalBackend = () => {
|
||||
startLocalSession,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
serialYmodemAvailable,
|
||||
selectFileAvailable,
|
||||
sendSerialYmodem,
|
||||
selectFile,
|
||||
execCommand,
|
||||
getSessionPwd,
|
||||
getSessionRemoteInfo,
|
||||
@@ -276,6 +316,7 @@ export const useTerminalBackend = () => {
|
||||
onTelnetAutoLoginCancelled,
|
||||
onChainProgress,
|
||||
onConnectionReuseFallback,
|
||||
onWindowFullScreenChanged,
|
||||
onHostKeyVerification,
|
||||
respondHostKeyVerification,
|
||||
openExternal,
|
||||
|
||||
21
application/state/useTerminalPopupWindow.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
|
||||
export function useTerminalPopupWindow() {
|
||||
const close = useCallback(async () => {
|
||||
await netcattyBridge.get()?.windowClose?.();
|
||||
}, []);
|
||||
|
||||
const setWindowTitle = useCallback(async (title: string) => {
|
||||
await netcattyBridge.get()?.setWindowTitle?.(title);
|
||||
}, []);
|
||||
|
||||
const onPopupConfig = useCallback((cb: (payload: TerminalPopupPayload) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTerminalPopupConfig) return () => {};
|
||||
return bridge.onTerminalPopupConfig(cb);
|
||||
}, []);
|
||||
|
||||
return { close, setWindowTitle, onPopupConfig };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import { sanitizeGroupConfig } from "../../domain/groupConfig";
|
||||
import { normalizeKnownHosts } from "../../domain/knownHosts";
|
||||
import {
|
||||
@@ -33,8 +33,11 @@ import {
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
|
||||
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
|
||||
import {
|
||||
decryptGroupConfigs,
|
||||
decryptHosts,
|
||||
@@ -89,6 +92,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
|
||||
((key.certificate ? "certificate" : "key") as KeyCategory),
|
||||
created: key.created || Date.now(),
|
||||
filePath: key.filePath,
|
||||
order: key.order,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -132,6 +136,11 @@ const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] =
|
||||
return changed ? next : logs;
|
||||
};
|
||||
|
||||
const readLegacyLineTimestampsEnabled = (): boolean => {
|
||||
const stored = localStorageAdapter.read<Record<string, unknown>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored?.showLineTimestamps === true;
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
@@ -167,7 +176,7 @@ export const useVaultState = () => {
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
|
||||
setHosts(cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
return encryptHosts(cleaned).then((enc) => {
|
||||
@@ -177,9 +186,10 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
return encryptKeys(data).then((enc) => {
|
||||
return encryptKeys(cleaned).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -210,8 +220,9 @@ export const useVaultState = () => {
|
||||
category: (draft.category || 'key') as KeyCategory,
|
||||
created: Date.now(),
|
||||
filePath: draft.filePath,
|
||||
order: getNextVaultOrder(keys),
|
||||
};
|
||||
const updated = [...keys, newKey];
|
||||
const updated = normalizeVaultOrder([...keys, newKey]);
|
||||
setKeys(updated);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
void encryptKeys(updated).then((enc) => {
|
||||
@@ -222,26 +233,29 @@ export const useVaultState = () => {
|
||||
}, [keys]);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setIdentities(cleaned);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
return encryptIdentities(data).then((enc) => {
|
||||
return encryptIdentities(cleaned).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
|
||||
setProxyProfiles(data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setProxyProfiles(cleaned);
|
||||
const ver = ++proxyProfilesWriteVersion.current;
|
||||
return encryptProxyProfiles(data).then((enc) => {
|
||||
return encryptProxyProfiles(cleaned).then((enc) => {
|
||||
if (ver === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
setSnippets(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setSnippets(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateSnippetPackages = useCallback((data: string[]) => {
|
||||
@@ -252,11 +266,39 @@ export const useVaultState = () => {
|
||||
const updateCustomGroups = useCallback((data: string[]) => {
|
||||
setCustomGroups(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
|
||||
}, []);
|
||||
|
||||
const groupOrderByPath = new Map<string, number>(
|
||||
data.map((path, index) => [path, (index + 1) * 1000]),
|
||||
);
|
||||
const existingConfigByPath = new Map<string, GroupConfig>(
|
||||
groupConfigs.map((config) => [config.path, config]),
|
||||
);
|
||||
const orderedConfigs = data.map((path) => {
|
||||
const existing = existingConfigByPath.get(path);
|
||||
const base: GroupConfig = existing ? { ...existing } : { path };
|
||||
return sanitizeGroupConfig({
|
||||
...base,
|
||||
path,
|
||||
order: groupOrderByPath.get(path),
|
||||
});
|
||||
});
|
||||
const retainedConfigs = groupConfigs.filter((config) => !groupOrderByPath.has(config.path));
|
||||
const cleanedGroupConfigs = normalizeVaultOrder([
|
||||
...orderedConfigs,
|
||||
...retainedConfigs.map(sanitizeGroupConfig),
|
||||
]);
|
||||
setGroupConfigs(cleanedGroupConfigs);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
void encryptGroupConfigs(cleanedGroupConfigs).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}, [groupConfigs]);
|
||||
|
||||
const updateKnownHosts = useCallback((data: KnownHost[]) => {
|
||||
setKnownHosts(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKnownHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
|
||||
}, []);
|
||||
|
||||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||||
@@ -270,7 +312,7 @@ export const useVaultState = () => {
|
||||
// pingfang-sc / comic-sans-ms override from an older client would
|
||||
// sit in memory and re-persist with `fontFamilyOverride: true` until
|
||||
// the next reload. Mirrors updateHosts → sanitizeHost.
|
||||
const cleaned = data.map(sanitizeGroupConfig);
|
||||
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(cleaned);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
return encryptGroupConfigs(cleaned).then((enc) => {
|
||||
@@ -306,14 +348,9 @@ export const useVaultState = () => {
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
|
||||
const newEntry: ShellHistoryEntry = {
|
||||
...entry,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setShellHistory((prev) => {
|
||||
// Keep only the last 1000 entries
|
||||
const updated = [newEntry, ...prev].slice(0, 1000);
|
||||
const updated = mergeGlobalHistoryOnAppend(prev, entry);
|
||||
if (updated === prev) return prev;
|
||||
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
|
||||
return updated;
|
||||
});
|
||||
@@ -400,6 +437,7 @@ export const useVaultState = () => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
// Update the known host to mark it as converted using functional update
|
||||
@@ -413,7 +451,7 @@ export const useVaultState = () => {
|
||||
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
const updated = normalizeVaultOrder([...prevHosts, sanitizeHost(newHost)]);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
@@ -423,7 +461,7 @@ export const useVaultState = () => {
|
||||
});
|
||||
|
||||
return newHost;
|
||||
}, []);
|
||||
}, [hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -437,7 +475,12 @@ export const useVaultState = () => {
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
const sanitized = normalizeVaultOrder(
|
||||
migrateHostsFromLegacyLineTimestamps(
|
||||
decrypted.map(sanitizeHost),
|
||||
readLegacyLineTimestampsEnabled(),
|
||||
),
|
||||
);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
@@ -474,8 +517,9 @@ export const useVaultState = () => {
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
const orderedKeys = normalizeVaultOrder(decryptedKeys);
|
||||
setKeys(orderedKeys);
|
||||
encryptKeys(orderedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -493,8 +537,9 @@ export const useVaultState = () => {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
const orderedIdentities = normalizeVaultOrder(decryptedIds);
|
||||
setIdentities(orderedIdentities);
|
||||
encryptIdentities(orderedIdentities).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
@@ -507,8 +552,9 @@ export const useVaultState = () => {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
setProxyProfiles(decryptedProfiles);
|
||||
encryptProxyProfiles(decryptedProfiles).then((enc) => {
|
||||
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
|
||||
setProxyProfiles(orderedProfiles);
|
||||
encryptProxyProfiles(orderedProfiles).then((enc) => {
|
||||
if (proxyVer === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
@@ -523,7 +569,11 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
if (savedSnippets) {
|
||||
const orderedSnippets = normalizeVaultOrder(savedSnippets);
|
||||
setSnippets(orderedSnippets);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
|
||||
}
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
@@ -540,9 +590,10 @@ export const useVaultState = () => {
|
||||
);
|
||||
if (savedKnownHosts) {
|
||||
const normalized = normalizeKnownHosts(savedKnownHosts);
|
||||
setKnownHosts(normalized);
|
||||
if (normalized !== savedKnownHosts) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
|
||||
const orderedKnownHosts = normalizeVaultOrder(normalized);
|
||||
setKnownHosts(orderedKnownHosts);
|
||||
if (normalized !== savedKnownHosts || orderedKnownHosts !== normalized) {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, orderedKnownHosts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +621,7 @@ export const useVaultState = () => {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
|
||||
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(sanitizedGC);
|
||||
encryptGroupConfigs(sanitizedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
@@ -605,7 +656,7 @@ export const useVaultState = () => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
setHosts(normalizeVaultOrder(dec.map(sanitizeHost)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -624,7 +675,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
setKeys(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -636,7 +687,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
setIdentities(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -648,14 +699,14 @@ export const useVaultState = () => {
|
||||
const writeAtStart = proxyProfilesWriteVersion.current;
|
||||
decryptProxyProfiles(next).then((dec) => {
|
||||
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
|
||||
setProxyProfiles(dec);
|
||||
setProxyProfiles(normalizeVaultOrder(dec));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
setSnippets(next);
|
||||
setSnippets(normalizeVaultOrder(next));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -673,7 +724,7 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_KNOWN_HOSTS) {
|
||||
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
|
||||
setKnownHosts(normalizeKnownHosts(next));
|
||||
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -702,7 +753,7 @@ export const useVaultState = () => {
|
||||
const writeAtStart = groupConfigsWriteVersion.current;
|
||||
decryptGroupConfigs(next).then((dec) => {
|
||||
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
|
||||
setGroupConfigs(dec.map(sanitizeGroupConfig));
|
||||
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export function subscribeWindowFullscreenChanged(
|
||||
cb: (isFullscreen: boolean) => void,
|
||||
): () => void {
|
||||
try {
|
||||
return netcattyBridge.get()?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
} catch {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useWindowControls = () => {
|
||||
const notifyRendererReady = useCallback(() => {
|
||||
try {
|
||||
@@ -45,10 +55,7 @@ export const useWindowControls = () => {
|
||||
return bridge?.windowIsFullscreen?.() ?? false;
|
||||
}, []);
|
||||
|
||||
const onFullscreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
|
||||
}, []);
|
||||
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
|
||||
|
||||
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Host } from '../../types';
|
||||
import type { VaultOrderPosition } from '../../domain/vaultOrder';
|
||||
|
||||
export interface VaultHostTreeActions {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onRenameHost: (host: Host) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onRenameGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
commitInlineGroupRename: (name: string) => void;
|
||||
cancelInlineGroupEdit: () => void;
|
||||
commitInlineHostRename: (name: string) => void;
|
||||
cancelInlineHostEdit: () => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
reorderHost: (sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => void;
|
||||
reorderGroup: (sourcePath: string, targetPath: string, position: VaultOrderPosition) => boolean;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,14 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.showHostTreeSidebar, false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
||||
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
||||
@@ -228,6 +236,24 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
settings: {
|
||||
showHostTreeSidebar: false,
|
||||
},
|
||||
syncedAt: 1,
|
||||
} as SyncPayload;
|
||||
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
|
||||
});
|
||||
|
||||
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
|
||||
// Without this nudge, the apply path writes to localStorage but
|
||||
// `useAIState` (listening for `storage` events) never sees the changes
|
||||
@@ -670,6 +696,49 @@ test("applySyncPayload preserves host proxy references when group configs are ab
|
||||
assert.equal("groupConfigs" in imported, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Inherited",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
},
|
||||
{
|
||||
id: "host-2",
|
||||
label: "Explicit",
|
||||
hostname: "example.net",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
showLineTimestamps: false,
|
||||
},
|
||||
],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { showLineTimestamps: true } },
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
const hosts = imported.hosts as SyncPayload["hosts"];
|
||||
assert.equal(hosts[0]?.showLineTimestamps, true);
|
||||
assert.equal(hosts[1]?.showLineTimestamps, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload waits for async vault imports", async () => {
|
||||
let finished = false;
|
||||
const payload: SyncPayload = {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import {
|
||||
nextCustomKeyBindingsSyncVersion,
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
@@ -63,6 +65,8 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -77,6 +81,7 @@ import {
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -191,8 +196,11 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
|
||||
'serverStatsRefreshInterval', 'rendererType',
|
||||
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
|
||||
'serverStatsRefreshInterval',
|
||||
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
|
||||
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
|
||||
'rendererType',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
@@ -227,6 +235,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -241,6 +250,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
STORAGE_KEY_AI_QUICK_MESSAGES,
|
||||
] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -404,6 +414,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
|
||||
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
@@ -441,6 +455,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
|
||||
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
|
||||
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
|
||||
if (Object.keys(ai).length > 0) settings.ai = ai;
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
@@ -524,7 +540,6 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
// SFTP Bookmarks (global only)
|
||||
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
|
||||
|
||||
// Immersive mode (legacy — always enabled, ignore incoming value)
|
||||
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
|
||||
if (settings.showOnlyUngroupedHostsInRoot != null) {
|
||||
localStorageAdapter.writeBoolean(
|
||||
@@ -535,6 +550,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
if (settings.showHostTreeSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
|
||||
}
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
@@ -570,6 +591,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ai.quickMessages != null) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
|
||||
}
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
@@ -610,6 +634,7 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
|
||||
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
|
||||
}
|
||||
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
|
||||
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
|
||||
for (const key of touched) {
|
||||
emitAIStateChanged(key);
|
||||
}
|
||||
@@ -702,10 +727,11 @@ function applyPayload(
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): Promise<void> {
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -2,6 +2,7 @@ import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
@@ -9,6 +10,11 @@ import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
import ConversationExport from './ai/ConversationExport';
|
||||
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
type ExportFormat = 'md' | 'json' | 'txt';
|
||||
@@ -60,6 +66,7 @@ interface AIChatPanelContentProps {
|
||||
terminalSessions: TerminalSessionSummary[];
|
||||
selectedUserSkills: UserSkillOption[];
|
||||
userSkillOptions: UserSkillOption[];
|
||||
quickMessages: AIQuickMessage[];
|
||||
addSelectedUserSkill: (slug: string) => void;
|
||||
removeSelectedUserSkill: (slug: string) => void;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
@@ -107,142 +114,169 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
terminalSessions,
|
||||
selectedUserSkills,
|
||||
userSkillOptions,
|
||||
quickMessages,
|
||||
addSelectedUserSkill,
|
||||
removeSelectedUserSkill,
|
||||
globalPermissionMode,
|
||||
setGlobalPermissionMode
|
||||
}) => (
|
||||
}) => {
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
|
||||
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
|
||||
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
|
||||
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
|
||||
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
|
||||
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
|
||||
<AgentSelector
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
onSelectAgent={handleAgentChange}
|
||||
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
onRediscover={rediscover}
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
{showHistory ? (
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
{showHistory && !hideHistory ? (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
|
||||
<SessionHistoryDrawer
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onClose={() => setShowHistory(false)}
|
||||
/>
|
||||
</React.Profiler>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat messages */}
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
{!hideMessages && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
/>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Recent sessions (Zed-style, shown when no messages) */}
|
||||
{messages.length === 0 && historySessions.length > 0 && (
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
|
||||
<div className="shrink-0 px-4 pb-1">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{t('ai.chat.viewAll')}
|
||||
</button>
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{historySessions.slice(0, 3).map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSelectSession(session.id)}
|
||||
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
||||
{formatRelativeTime(new Date(session.updatedAt), t)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</React.Profiler>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
{!hideInput && (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
||||
<ChatInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
disabled={!canSendCurrentAgent}
|
||||
providerName={providerDisplayName}
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={
|
||||
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
||||
? {
|
||||
providers: cattyConfiguredProviders,
|
||||
selectedProviderId: effectiveActiveProvider?.id,
|
||||
selectedModelId: effectiveActiveModelId || undefined,
|
||||
onSelect: handleAgentProviderModelSelect,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkills={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
onAddUserSkill={addSelectedUserSkill}
|
||||
onRemoveUserSkill={removeSelectedUserSkill}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
/>
|
||||
</React.Profiler>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
102
components/AIChatSidePanel.mountRetention.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AIDraft, AISession } from '../infrastructure/ai/types';
|
||||
import {
|
||||
hasAIChatSidePanelRetainedContent,
|
||||
shouldKeepAIChatSidePanelMounted,
|
||||
} from './AIChatSidePanel.tsx';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
|
||||
|
||||
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
|
||||
text: '',
|
||||
agentId: 'catty',
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const session = (overrides: Partial<AISession> = {}): AISession => ({
|
||||
id: 'session-1',
|
||||
title: 'Session',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-1' },
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
|
||||
sessions: [],
|
||||
activeSessionIdMap: {},
|
||||
draftsByScope: {},
|
||||
panelViewByScope: {},
|
||||
setActiveSessionId: () => undefined,
|
||||
ensureDraftForScope: () => undefined,
|
||||
updateDraft: () => undefined,
|
||||
showDraftView: () => undefined,
|
||||
showSessionView: () => undefined,
|
||||
clearDraftForScope: () => undefined,
|
||||
addDraftFiles: async () => undefined,
|
||||
removeDraftFile: () => undefined,
|
||||
createSession: () => session(),
|
||||
deleteSession: () => undefined,
|
||||
updateSessionTitle: () => undefined,
|
||||
updateSessionExternalSessionId: () => undefined,
|
||||
addMessageToSession: () => undefined,
|
||||
updateLastMessage: () => undefined,
|
||||
updateMessageById: () => undefined,
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
activeModelId: '',
|
||||
defaultAgentId: 'catty',
|
||||
toolIntegrationMode: 'mcp',
|
||||
externalAgents: [],
|
||||
agentModelMap: {},
|
||||
setAgentModel: () => undefined,
|
||||
agentProviderMap: {},
|
||||
setAgentProvider: () => undefined,
|
||||
globalPermissionMode: 'autonomous',
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: 'terminal-1',
|
||||
isVisible: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('hidden empty AI side panel can release its subtree', () => {
|
||||
const props = baseProps();
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has draft text', () => {
|
||||
const props = baseProps({
|
||||
draftsByScope: {
|
||||
'terminal:terminal-1': draft({ text: 'hello' }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has session messages', () => {
|
||||
const props = baseProps({
|
||||
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
|
||||
sessions: [
|
||||
session({
|
||||
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
|
||||
});
|
||||
|
||||
test('visible AI side panel is always mounted even when empty', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
panelViewsEqual,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from './ai/aiPanelViewState';
|
||||
@@ -47,15 +48,45 @@ import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligib
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
|
||||
import { generateId, modelPresetsContainId, shouldLoadSdkRuntimeModels } from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import {
|
||||
getAIPanelProfilerProps,
|
||||
profileAIPanelCalculation,
|
||||
} from './ai/aiPanelDiagnostics';
|
||||
|
||||
function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
|
||||
export function hasAIChatSidePanelRetainedContent(props: Pick<
|
||||
AIChatSidePanelProps,
|
||||
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
|
||||
>): boolean {
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
const activeSession = sessionId
|
||||
? props.sessions.find((session) => session.id === sessionId)
|
||||
: null;
|
||||
if (activeSession && activeSession.messages.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const draft = props.draftsByScope[scopeKey] ?? null;
|
||||
return Boolean(
|
||||
draft
|
||||
&& (
|
||||
draft.text.trim().length > 0
|
||||
|| draft.attachments.length > 0
|
||||
|| draft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
|
||||
if (props.isVisible ?? true) {
|
||||
return true;
|
||||
}
|
||||
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
|
||||
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
|
||||
if (hasAIChatSidePanelRetainedContent(props)) {
|
||||
return true;
|
||||
}
|
||||
return isAIChatSessionStreaming(sessionId);
|
||||
}
|
||||
|
||||
@@ -95,6 +126,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
commandBlocklist,
|
||||
maxIterations = 20,
|
||||
webSearchConfig,
|
||||
quickMessages = [],
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
@@ -146,12 +178,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const deferredSessions = useDeferredValue(sessions);
|
||||
const historySessions = useMemo(
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
() => profileAIPanelCalculation(
|
||||
'AIChatSidePanel.historySessions',
|
||||
() => getScopedHistorySessions(
|
||||
deferredSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
),
|
||||
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
@@ -219,7 +254,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
||||
if (!explicitPanelView || panelViewsEqual(normalizedPanelView, explicitPanelView)) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
@@ -448,18 +483,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isCodexManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
const isClaudeManagedAgent = useMemo(
|
||||
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
@@ -496,7 +523,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!isVisible) return;
|
||||
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
|
||||
if (!sdkBackend) return;
|
||||
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
|
||||
if (!shouldLoadSdkRuntimeModels(currentAgentConfig) && !isCodexManagedAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiSdkAgentListModels) return;
|
||||
@@ -536,7 +563,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
|
||||
}, [isVisible, currentAgentConfig, currentAgentId, isCodexManagedAgent, setAgentModel]);
|
||||
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
|
||||
|
||||
@@ -826,22 +853,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
const controller = abortControllersRef.current.get(activeSessionId);
|
||||
const stopStreamingForSession = useCallback((sessionId: string) => {
|
||||
const controller = abortControllersRef.current.get(sessionId);
|
||||
controller?.abort();
|
||||
abortControllersRef.current.delete(activeSessionId);
|
||||
setStreamingForScope(activeSessionId, false);
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
setStreamingForScope(sessionId, false);
|
||||
updateLastMessage(sessionId, (msg) => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
clearAllPendingApprovals(sessionId);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
bridge?.aiCattyCancelExec?.(sessionId);
|
||||
bridge?.aiSdkAgentCancel?.('', sessionId);
|
||||
}, [setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!activeSessionId) return;
|
||||
stopStreamingForSession(activeSessionId);
|
||||
}, [activeSessionId, stopStreamingForSession]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -857,9 +888,43 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const deletingActiveSession =
|
||||
activeSessionId === sessionId
|
||||
|| persistedSessionId === sessionId
|
||||
|| (
|
||||
explicitPanelView?.mode === 'session'
|
||||
&& explicitPanelView.sessionId === sessionId
|
||||
);
|
||||
const deletingLastScopedSession =
|
||||
historySessions.length === 1 && historySessions[0]?.id === sessionId;
|
||||
const deletedSessionAgentId =
|
||||
historySessions.find((session) => session.id === sessionId)?.agentId
|
||||
?? currentAgentId;
|
||||
|
||||
if (abortControllersRef.current.has(sessionId) || streamingSessionIds.has(sessionId)) {
|
||||
stopStreamingForSession(sessionId);
|
||||
}
|
||||
|
||||
deleteSession(sessionId, scopeKey);
|
||||
|
||||
if (deletingActiveSession || deletingLastScopedSession) {
|
||||
setShowHistory(false);
|
||||
ensureScopeDraft(deletedSessionAgentId);
|
||||
}
|
||||
},
|
||||
[deleteSession, scopeKey],
|
||||
[
|
||||
activeSessionId,
|
||||
abortControllersRef,
|
||||
currentAgentId,
|
||||
deleteSession,
|
||||
ensureScopeDraft,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
persistedSessionId,
|
||||
scopeKey,
|
||||
stopStreamingForSession,
|
||||
streamingSessionIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
@@ -877,52 +942,55 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
|
||||
return (
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
handleAgentChange={handleAgentChange}
|
||||
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
rediscover={rediscover}
|
||||
handleOpenSettings={handleOpenSettings}
|
||||
activeSession={activeSession}
|
||||
handleExport={handleExport}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
handleNewChat={handleNewChat}
|
||||
historySessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleStop={handleStop}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
|
||||
<AIChatPanelContent
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
discoveredAgents={discoveredAgents}
|
||||
isDiscovering={isDiscovering}
|
||||
handleAgentChange={handleAgentChange}
|
||||
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
||||
rediscover={rediscover}
|
||||
handleOpenSettings={handleOpenSettings}
|
||||
activeSession={activeSession}
|
||||
handleExport={handleExport}
|
||||
showHistory={showHistory}
|
||||
setShowHistory={setShowHistory}
|
||||
handleNewChat={handleNewChat}
|
||||
historySessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleStop={handleStop}
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
agentModelPresets={agentModelPresets}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
effectiveActiveProvider={effectiveActiveProvider}
|
||||
effectiveActiveModelId={effectiveActiveModelId}
|
||||
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
|
||||
files={files}
|
||||
addFiles={addFiles}
|
||||
removeFile={removeFile}
|
||||
terminalSessions={terminalSessions}
|
||||
selectedUserSkills={selectedUserSkills}
|
||||
userSkillOptions={userSkillOptions}
|
||||
quickMessages={quickMessages}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
</React.Profiler>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -963,6 +1031,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'commandBlocklist',
|
||||
'maxIterations',
|
||||
'webSearchConfig',
|
||||
'quickMessages',
|
||||
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
|
||||
|
||||
function aiChatSidePanelPropsAreEqual(
|
||||
@@ -992,14 +1061,10 @@ function aiChatSidePanelPropsAreEqual(
|
||||
}
|
||||
|
||||
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.
|
||||
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
|
||||
// Keep hidden panels alive only when they contain real work (messages, draft
|
||||
// content, or an active stream). Empty hidden panels can drop their heavy
|
||||
// input/agent-picker subtree and remount cheaply when shown again.
|
||||
return <AIChatSidePanelActive {...props} />;
|
||||
}, aiChatSidePanelPropsAreEqual);
|
||||
AIChatSidePanel.displayName = 'AIChatSidePanel';
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -72,6 +73,9 @@ export interface AIChatSidePanelProps {
|
||||
// Web search
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
|
||||
// Quick messages (slash prompts)
|
||||
quickMessages?: AIQuickMessage[];
|
||||
|
||||
// Context
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
|
||||
35
components/AIChatSidePanelHelpers.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
modelPresetsContainId,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
test('modelPresetsContainId matches plain and thinking-level model ids', () => {
|
||||
const presets: AgentModelPreset[] = [
|
||||
{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] },
|
||||
{ id: 'claude-sonnet', name: 'Claude Sonnet' },
|
||||
];
|
||||
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/high'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'claude-sonnet'), true);
|
||||
assert.equal(modelPresetsContainId(presets, 'gpt-5.5/medium'), false);
|
||||
});
|
||||
|
||||
test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () => {
|
||||
const agent = (sdkBackend: string): ExternalAgentConfig => ({
|
||||
id: `discovered_${sdkBackend}`,
|
||||
name: sdkBackend,
|
||||
command: sdkBackend,
|
||||
enabled: true,
|
||||
sdkBackend,
|
||||
});
|
||||
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('claude')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('copilot')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
|
||||
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
|
||||
});
|
||||
@@ -26,6 +26,11 @@ export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
return sdkBackend === 'claude' || sdkBackend === 'copilot' || sdkBackend === 'codebuddy';
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
Bookmark,
|
||||
ChevronDown,
|
||||
CircleUserRound,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -66,6 +67,7 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,8 +84,8 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* User column */}
|
||||
<div className="flex items-center gap-2 w-56 shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<User size={14} />
|
||||
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
|
||||
<CircleUserRound size={18} strokeWidth={2.25} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{log.localUsername}</div>
|
||||
@@ -93,12 +95,28 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* Host column */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
{hasPersistedHostIcon ? (
|
||||
<DistroAvatar
|
||||
host={{
|
||||
os: log.hostOs ?? "linux",
|
||||
distro: log.hostDistro,
|
||||
distroMode: "auto",
|
||||
}}
|
||||
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
|
||||
isSerial
|
||||
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
|
||||
: isLocal
|
||||
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
|
||||
@@ -68,11 +68,12 @@ export const DISTRO_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
type DistroAvatarProps = {
|
||||
host: Host;
|
||||
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
|
||||
Partial<Pick<Host, "protocol">>;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
/** xs matches top tab bar icons (h-4 rounded rect) */
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
size?: "xs" | "sm" | "md" | "tree" | "log" | "lg";
|
||||
};
|
||||
|
||||
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
@@ -91,12 +92,16 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
xs: "h-4 w-4 rounded",
|
||||
sm: "h-5 w-5 rounded",
|
||||
md: "h-8 w-8 rounded",
|
||||
lg: "h-11 w-11 rounded",
|
||||
tree: "h-8 w-8 rounded-lg",
|
||||
log: "h-9 w-9 rounded-xl",
|
||||
lg: "h-11 w-11 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
xs: "h-2.5 w-2.5",
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
tree: "h-4 w-4",
|
||||
log: "h-5 w-5",
|
||||
lg: "h-5 w-5",
|
||||
};
|
||||
|
||||
@@ -108,7 +113,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
@@ -141,7 +146,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded flex items-center justify-center bg-primary/15 text-primary",
|
||||
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
|
||||
containerClass,
|
||||
className,
|
||||
)}
|
||||
|
||||
554
components/HistorySidePanel.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* HistorySidePanel — command history browser for the terminal side panel.
|
||||
*
|
||||
* Two scopes:
|
||||
* - Host: remote shell history read from the focused session's history file.
|
||||
* - Global: commands recorded locally as the user types across all sessions.
|
||||
*
|
||||
* Uses VariableSizeVirtualList for performance with large lists (up to 1000
|
||||
* entries). Long commands are truncated in the list; click a row to expand the
|
||||
* full text inline below that row.
|
||||
*/
|
||||
|
||||
import {
|
||||
Clipboard as ClipboardIcon,
|
||||
FileCode,
|
||||
Globe,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Terminal as TerminalIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
type VariableSizeVirtualListHandle,
|
||||
} from './ui/VariableSizeVirtualList';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export type HistoryPanelScope = 'host' | 'global';
|
||||
|
||||
export interface HistorySidePanelProps {
|
||||
focusedHost: Host | null;
|
||||
focusedSessionId: string | null;
|
||||
state: RemoteHistoryHostState;
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
onRunInTerminal: (command: string) => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const SUPPORTED_PROTOCOLS = new Set(['ssh', 'mosh', 'et']);
|
||||
const HISTORY_ROW_HEIGHT = 36;
|
||||
const HISTORY_ROW_WITH_HOST_HEIGHT = 46;
|
||||
const DETAIL_PADDING_Y = 12;
|
||||
const DETAIL_LINE_HEIGHT = 16;
|
||||
const DETAIL_MAX_COMMAND_LINES = 3;
|
||||
const DETAIL_TIMESTAMP_HEIGHT = 14;
|
||||
const DETAIL_HOST_LABEL_HEIGHT = 14;
|
||||
const DETAIL_ACTIONS_HEIGHT = 24;
|
||||
|
||||
interface HistoryPanelEntry {
|
||||
id: string;
|
||||
command: string;
|
||||
timestamp?: number;
|
||||
hostLabel?: string;
|
||||
}
|
||||
|
||||
function getDetailRowHeight(entry: HistoryPanelEntry): number {
|
||||
const lineCount = Math.min(
|
||||
entry.command.split('\n').length,
|
||||
DETAIL_MAX_COMMAND_LINES,
|
||||
);
|
||||
const commandHeight = Math.max(lineCount, 1) * DETAIL_LINE_HEIGHT;
|
||||
const timestampBlock = entry.timestamp ? DETAIL_TIMESTAMP_HEIGHT + 4 : 0;
|
||||
const hostLabelBlock = entry.hostLabel ? DETAIL_HOST_LABEL_HEIGHT + 2 : 0;
|
||||
return DETAIL_PADDING_Y + commandHeight + timestampBlock + hostLabelBlock + 4 + DETAIL_ACTIONS_HEIGHT;
|
||||
}
|
||||
|
||||
type HistoryListRow =
|
||||
| { type: 'entry'; entry: HistoryPanelEntry }
|
||||
| { type: 'detail'; entry: HistoryPanelEntry };
|
||||
|
||||
function buildHistoryListRows(
|
||||
entries: HistoryPanelEntry[],
|
||||
selectedEntryId: string | null,
|
||||
): HistoryListRow[] {
|
||||
const rows: HistoryListRow[] = [];
|
||||
for (const entry of entries) {
|
||||
rows.push({ type: 'entry', entry });
|
||||
if (selectedEntryId === entry.id) {
|
||||
rows.push({ type: 'detail', entry });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function remoteToPanelEntries(entries: RemoteHistoryEntry[]): HistoryPanelEntry[] {
|
||||
return entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
command: entry.command,
|
||||
timestamp: entry.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
state,
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [scope, setScope] = useState<HistoryPanelScope>('host');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const listRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
|
||||
const protocol = focusedHost?.protocol;
|
||||
const isSupportedSession =
|
||||
!!focusedHost && !!focusedSessionId && SUPPORTED_PROTOCOLS.has(String(protocol ?? 'ssh'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || scope !== 'host' || !isSupportedSession || !focusedHost || !focusedSessionId) {
|
||||
return;
|
||||
}
|
||||
if (state.loading) return;
|
||||
if (state.fetchedAt != null || state.error) return;
|
||||
onFetch(focusedSessionId, focusedHost.id);
|
||||
}, [
|
||||
isVisible,
|
||||
scope,
|
||||
isSupportedSession,
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
state.loading,
|
||||
state.fetchedAt,
|
||||
state.error,
|
||||
onFetch,
|
||||
]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (!focusedHost || !focusedSessionId) return;
|
||||
onFetch(focusedSessionId, focusedHost.id);
|
||||
}, [focusedHost, focusedSessionId, onFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scope !== 'host') return;
|
||||
setSelectedEntryId(null);
|
||||
setSearch('');
|
||||
}, [focusedHost?.id, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryId(null);
|
||||
}, [scope]);
|
||||
|
||||
const sourceEntries = useMemo((): HistoryPanelEntry[] => {
|
||||
if (scope === 'global') {
|
||||
return toGlobalHistoryDisplayEntries(globalEntries);
|
||||
}
|
||||
return remoteToPanelEntries(state.entries);
|
||||
}, [scope, globalEntries, state.entries]);
|
||||
|
||||
const filtered = useMemo((): HistoryPanelEntry[] => {
|
||||
if (!search.trim()) return sourceEntries;
|
||||
const q = search.toLowerCase();
|
||||
return sourceEntries.filter(
|
||||
(entry) =>
|
||||
entry.command.toLowerCase().includes(q)
|
||||
|| entry.hostLabel?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [sourceEntries, search]);
|
||||
|
||||
const listRows = useMemo(
|
||||
() => buildHistoryListRows(filtered, selectedEntryId),
|
||||
[filtered, selectedEntryId],
|
||||
);
|
||||
|
||||
const handleSaveAsSnippet = useCallback((entry: HistoryPanelEntry) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('netcatty:snippets:add', {
|
||||
detail: { command: entry.command },
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
if (next) {
|
||||
requestAnimationFrame(() => {
|
||||
const detailIndex = buildHistoryListRows(filtered, next).findIndex(
|
||||
(row) => row.type === 'detail' && row.entry.id === next,
|
||||
);
|
||||
if (detailIndex >= 0) {
|
||||
listRef.current?.scrollToIndex(detailIndex, 'auto');
|
||||
}
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
(row: HistoryListRow) => {
|
||||
if (row.type === 'detail') return getDetailRowHeight(row.entry);
|
||||
if (scope === 'global' && row.entry.hostLabel) return HISTORY_ROW_WITH_HOST_HEIGHT;
|
||||
return HISTORY_ROW_HEIGHT;
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
paste: t('history.action.paste'),
|
||||
run: t('history.action.run'),
|
||||
save: t('history.action.saveAsSnippet'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const entryCount = sourceEntries.length;
|
||||
const showHostEmpty = scope === 'host' && !focusedHost;
|
||||
const showUnsupported = scope === 'host' && focusedHost && !isSupportedSession;
|
||||
const showLoading = scope === 'host' && focusedHost && isSupportedSession && state.loading && state.entries.length === 0;
|
||||
const showError = scope === 'host' && focusedHost && isSupportedSession && state.error;
|
||||
const showNoRemoteHistory =
|
||||
scope === 'host'
|
||||
&& focusedHost
|
||||
&& isSupportedSession
|
||||
&& !state.loading
|
||||
&& !state.error
|
||||
&& state.entries.length === 0;
|
||||
const showNoGlobalHistory = scope === 'global' && globalEntries.length === 0;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="history-panel"
|
||||
data-history-scope={scope}
|
||||
>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('history.searchPlaceholder')}
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
{scope === 'host' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={!isSupportedSession || state.loading}
|
||||
title={t('history.action.refresh')}
|
||||
aria-label={t('history.action.refresh')}
|
||||
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
|
||||
<div
|
||||
className="inline-flex max-w-[calc(100%-3.5rem)] items-center gap-0.5"
|
||||
role="tablist"
|
||||
aria-label={t('history.scope.label')}
|
||||
>
|
||||
<ScopeTab
|
||||
active={scope === 'host'}
|
||||
label={focusedHost?.label ?? t('history.tab.host')}
|
||||
icon={<TerminalIcon size={10} className="shrink-0" />}
|
||||
onClick={() => setScope('host')}
|
||||
className="max-w-[9rem]"
|
||||
/>
|
||||
<ScopeTab
|
||||
active={scope === 'global'}
|
||||
label={t('history.tab.global')}
|
||||
icon={<Globe size={10} className="shrink-0" />}
|
||||
onClick={() => setScope('global')}
|
||||
/>
|
||||
</div>
|
||||
{entryCount > 0 && (
|
||||
<span className="ml-auto shrink-0 opacity-70">
|
||||
{t('history.meta.count', { count: entryCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{showHostEmpty && (
|
||||
<EmptyState message={t('history.empty.noSession')} />
|
||||
)}
|
||||
|
||||
{showUnsupported && (
|
||||
<EmptyState message={t('history.empty.unsupportedProtocol')} />
|
||||
)}
|
||||
|
||||
{showLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
|
||||
<RefreshCw size={20} className="opacity-60 mb-2 animate-spin" />
|
||||
<span className="text-xs">{t('history.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showError && (
|
||||
<div className="px-3 py-4 text-xs text-center">
|
||||
<div className="text-destructive mb-2">{state.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t('history.action.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoRemoteHistory && (
|
||||
<EmptyState message={t('history.empty.noHistory')} />
|
||||
)}
|
||||
|
||||
{showNoGlobalHistory && (
|
||||
<EmptyState message={t('history.empty.noGlobalHistory')} />
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && sourceEntries.length > 0 && (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listRows.length > 0 && (
|
||||
<VariableSizeVirtualList
|
||||
ref={listRef}
|
||||
items={listRows}
|
||||
getItemHeight={getRowHeight}
|
||||
getItemKey={(row, index) =>
|
||||
row.type === 'entry' ? row.entry.id : `detail-${row.entry.id}-${index}`}
|
||||
renderItem={(row) => {
|
||||
if (row.type === 'detail') {
|
||||
return (
|
||||
<HistoryDetailStrip
|
||||
entry={row.entry}
|
||||
labels={labels}
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HistoryRow
|
||||
entry={row.entry}
|
||||
isSelected={selectedEntryId === row.entry.id}
|
||||
showHostLabel={scope === 'global'}
|
||||
labels={labels}
|
||||
onSelect={() => handleRowClick(row.entry.id)}
|
||||
onRun={() => onRunInTerminal(row.entry.command)}
|
||||
onPaste={() => onPasteToTerminal(row.entry.command)}
|
||||
onSave={() => handleSaveAsSnippet(row.entry)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScopeTab: React.FC<{
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}> = ({ active, label, icon, onClick, className }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[10px] leading-4 transition-colors min-w-0 shrink whitespace-nowrap',
|
||||
active
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const EmptyState: React.FC<{ message: string }> = ({ message }) => (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
|
||||
<TerminalIcon size={24} className="opacity-40 mb-2" />
|
||||
<span className="text-xs">{message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface HistoryDetailStripProps {
|
||||
entry: HistoryPanelEntry;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
|
||||
({ entry, labels, onRun, onPaste, onSave }) => (
|
||||
<div
|
||||
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
|
||||
data-section="history-detail"
|
||||
>
|
||||
<div
|
||||
className="font-mono text-[11px] leading-4 whitespace-pre-wrap break-words line-clamp-3 overflow-hidden"
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{entry.command}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1 min-h-6">
|
||||
<div className="flex-1 min-w-0">
|
||||
{entry.hostLabel ? (
|
||||
<span className="block text-[10px] text-muted-foreground truncate">
|
||||
{entry.hostLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.timestamp ? (
|
||||
<span className="block text-[10px] text-muted-foreground truncate">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<IconButton title={labels.run} onClick={onRun}>
|
||||
<Play size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.paste} onClick={onPaste}>
|
||||
<ClipboardIcon size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
HistoryDetailStrip.displayName = 'HistoryDetailStrip';
|
||||
|
||||
interface HistoryRowProps {
|
||||
entry: HistoryPanelEntry;
|
||||
isSelected: boolean;
|
||||
showHostLabel: boolean;
|
||||
labels: { paste: string; run: string; save: string };
|
||||
onSelect: () => void;
|
||||
onRun: () => void;
|
||||
onPaste: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const HistoryRow: React.FC<HistoryRowProps> = memo(
|
||||
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.detail > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const rowTitle = isSelected
|
||||
? undefined
|
||||
: [entry.command, showHostLabel && entry.hostLabel ? entry.hostLabel : null]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex select-none items-center gap-2 px-3 h-full hover:bg-accent/50 transition-colors cursor-pointer',
|
||||
isSelected && 'bg-accent/30',
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isSelected}
|
||||
title={rowTitle}
|
||||
onClick={onSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="w-0 flex-1 min-w-0">
|
||||
<div className="font-mono text-[11px] truncate whitespace-nowrap">
|
||||
{entry.command}
|
||||
</div>
|
||||
{showHostLabel && entry.hostLabel ? (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{entry.hostLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<IconButton title={labels.run} onClick={onRun}>
|
||||
<Play size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.paste} onClick={onPaste}>
|
||||
<ClipboardIcon size={12} />
|
||||
</IconButton>
|
||||
<IconButton title={labels.save} onClick={onSave}>
|
||||
<FileCode size={12} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
HistoryRow.displayName = 'HistoryRow';
|
||||
|
||||
const IconButton: React.FC<{
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, onClick, children }) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const HistorySidePanel = memo(HistorySidePanelInner);
|
||||
HistorySidePanel.displayName = 'HistorySidePanel';
|
||||
@@ -370,6 +370,12 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.terminalBehavior")}
|
||||
>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.lineTimestamps")}
|
||||
hint={t("hostDetails.lineTimestamps.desc")}
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
hostTreeInlineGroupEditStore,
|
||||
@@ -9,6 +9,7 @@ import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActio
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
@@ -20,10 +21,24 @@ import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
element: HTMLElement,
|
||||
clientY: number,
|
||||
): 'before' | 'inside' | 'after' => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
|
||||
if (clientY <= rect.top + edgeSize) return 'before';
|
||||
if (clientY >= rect.bottom - edgeSize) return 'after';
|
||||
return 'inside';
|
||||
};
|
||||
|
||||
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
|
||||
Array.from(dataTransfer.types).includes(type);
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
sortMode?: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
@@ -55,7 +70,7 @@ interface HostTreeViewProps {
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
sortMode: 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
@@ -136,10 +151,26 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
const originalIndex = new Map(nodes.map((child, index) => [child.path, index]));
|
||||
const orderByPath = new Map(
|
||||
groupConfigs
|
||||
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
|
||||
.map((config) => [config.path, config.order as number]),
|
||||
);
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'manual': {
|
||||
const orderA = orderByPath.get(a.path);
|
||||
const orderB = orderByPath.get(b.path);
|
||||
const hasOrderA = typeof orderA === 'number' && Number.isFinite(orderA);
|
||||
const hasOrderB = typeof orderB === 'number' && Number.isFinite(orderB);
|
||||
if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB;
|
||||
if (hasOrderA) return -1;
|
||||
if (hasOrderB) return 1;
|
||||
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
|
||||
}
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
@@ -149,10 +180,10 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
}, [groupConfigs, node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
const sorted = [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
@@ -162,31 +193,54 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case 'manual':
|
||||
return 0;
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
if (sortMode === 'manual') return sortByVaultOrder(sorted);
|
||||
return sorted;
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => {
|
||||
if (isInlineEditing) return;
|
||||
onToggle(node.path);
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
ref={groupRowRef}
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
getDropTargetClasses?.(node.path),
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="group"
|
||||
data-group-path={node.path}
|
||||
draggable={!isInlineEditing}
|
||||
onDragStart={(e) => {
|
||||
if (isInlineEditing) return;
|
||||
e.dataTransfer.setData("group-path", node.path);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (hasDragType(e.dataTransfer, "group-path")) {
|
||||
const intent = getTreeGroupDropIntent(e.currentTarget, e.clientY);
|
||||
if (intent !== "inside") {
|
||||
setDragOverDropTarget?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDragOverDropTarget?.(node.path);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
@@ -203,7 +257,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
@@ -213,8 +269,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={21} strokeWidth={2.35} />
|
||||
) : (
|
||||
<Folder size={21} strokeWidth={2.35} />
|
||||
)}
|
||||
</div>
|
||||
{isInlineEditing && commitRename && cancelRename ? (
|
||||
<HostTreeGroupInlineRenameInput
|
||||
@@ -359,7 +419,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost: _onDuplicateHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
@@ -386,10 +446,13 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
"vault-drop-indicator-row flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
data-section="host-tree-row"
|
||||
data-row-type="host"
|
||||
data-host-id={host.id}
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => {
|
||||
@@ -414,7 +477,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="xs" />
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate flex items-center gap-1.5">
|
||||
@@ -452,6 +515,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
@@ -491,6 +555,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupConfigs = [],
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const inlineEdit = useHostTreeInlineGroupEdit();
|
||||
const vaultTreeActions = useVaultHostTreeActions();
|
||||
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
|
||||
|
||||
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!inlineEdit?.groupPath || !cancelRename) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('[data-inline-group-edit="true"]')) return;
|
||||
const row = target.closest('[data-section="host-tree-row"]');
|
||||
if (!row) return;
|
||||
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
|
||||
cancelRename();
|
||||
}, [cancelRename, inlineEdit?.groupPath]);
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
@@ -528,7 +606,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
const sorted = hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
@@ -538,10 +616,14 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case 'manual':
|
||||
return 0;
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
if (sortMode === 'manual') return sortByVaultOrder(sorted);
|
||||
return sorted;
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
@@ -550,6 +632,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'manual':
|
||||
return 0;
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
@@ -562,7 +646,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
Upload,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import type { GroupConfig } from "../domain/models";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -48,7 +49,9 @@ import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
import {
|
||||
@@ -79,8 +82,10 @@ interface KeychainManagerProps {
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
onReorderKeys?: (keys: SSHKey[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSaveIdentity?: (identity: Identity) => void;
|
||||
onReorderIdentities?: (identities: Identity[]) => void;
|
||||
onDeleteIdentity?: (id: string) => void;
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -97,8 +102,10 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
onReorderKeys,
|
||||
onDelete,
|
||||
onSaveIdentity,
|
||||
onReorderIdentities,
|
||||
onDeleteIdentity,
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
@@ -146,6 +153,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
|
||||
const [showHostSelector, setShowHostSelector] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Export panel state
|
||||
const [exportLocation, setExportLocation] = useState(".ssh");
|
||||
@@ -170,6 +178,27 @@ echo $3 >> "$FILE"`);
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const keyReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "key-id",
|
||||
targetAttribute: "data-key-id",
|
||||
disabled: !onReorderKeys || search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorderKeys?.(reorderVaultItems(keys, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
const identityReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "identity-id",
|
||||
targetAttribute: "data-identity-id",
|
||||
disabled: !onReorderIdentities || search.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorderIdentities?.(reorderVaultItems(identities, sourceId, targetId, position));
|
||||
},
|
||||
});
|
||||
|
||||
const showError = useCallback((message: string, title = t("common.error")) => {
|
||||
toast.error(message, title);
|
||||
}, [t]);
|
||||
@@ -203,18 +232,18 @@ echo $3 >> "$FILE"`);
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
return sortByVaultOrder(result);
|
||||
}, [keys, activeFilter, search]);
|
||||
|
||||
// Filter identities based on search
|
||||
const filteredIdentities = useMemo(() => {
|
||||
if (!search.trim()) return identities;
|
||||
if (!search.trim()) return sortByVaultOrder(identities);
|
||||
const s = search.toLowerCase();
|
||||
return identities.filter(
|
||||
return sortByVaultOrder(identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(s) ||
|
||||
i.username.toLowerCase().includes(s),
|
||||
);
|
||||
));
|
||||
}, [identities, search]);
|
||||
|
||||
// Push a new panel onto the stack
|
||||
@@ -674,11 +703,30 @@ echo $3 >> "$FILE"`);
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onDragOverCapture={(event) => {
|
||||
keyReorder.handleDragOverCapture(event);
|
||||
identityReorder.handleDragOverCapture(event);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
keyReorder.handleDragOver(event);
|
||||
identityReorder.handleDragOver(event);
|
||||
}}
|
||||
onDropCapture={(event) => {
|
||||
keyReorder.handleDropCapture(event);
|
||||
identityReorder.handleDropCapture(event);
|
||||
}}
|
||||
onDragEndCapture={() => {
|
||||
keyReorder.handleDragEndCapture();
|
||||
identityReorder.handleDragEndCapture();
|
||||
}}
|
||||
>
|
||||
{/* Keys Section */}
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("keychain.section.keys")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -728,6 +776,7 @@ echo $3 >> "$FILE"`);
|
||||
(panel.type === "export" && panel.key.id === key.id)
|
||||
}
|
||||
isMac={isMacOS()}
|
||||
reorderProps={keyReorder.getItemReorderProps(key.id, `key:${key.id}`)}
|
||||
onClick={() => openKeyView(key)}
|
||||
onEdit={() => openKeyEdit(key)}
|
||||
onExport={() => openKeyExport(key)}
|
||||
@@ -743,7 +792,7 @@ echo $3 >> "$FILE"`);
|
||||
{activeFilter === "key" && filteredIdentities.length > 0 && (
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
<h2 className={vaultSectionTitleClass}>
|
||||
{t("keychain.section.identities")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -767,6 +816,7 @@ echo $3 >> "$FILE"`);
|
||||
panel.type === "identity" &&
|
||||
panel.identity?.id === identity.id
|
||||
}
|
||||
reorderProps={identityReorder.getItemReorderProps(identity.id, `identity:${identity.id}`)}
|
||||
onClick={() => {
|
||||
setPanelStack([{ type: "identity", identity }]);
|
||||
setDraftIdentity({ ...identity });
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
|
||||
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
|
||||
import { fingerprintFromPublicKey } from "../domain/knownHosts";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -44,12 +45,15 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
hosts: Host[];
|
||||
onSave: (knownHost: KnownHost) => void;
|
||||
onUpdate: (knownHost: KnownHost) => void;
|
||||
onReorder: (knownHosts: KnownHost[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
onImportFromFile: (hosts: KnownHost[]) => void;
|
||||
@@ -114,12 +118,13 @@ interface HostItemProps {
|
||||
knownHost: KnownHost;
|
||||
converted: boolean;
|
||||
viewMode: ViewMode;
|
||||
reorderProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
onDelete: (id: string) => void;
|
||||
onConvertToHost: (knownHost: KnownHost) => void;
|
||||
}
|
||||
|
||||
const HostItem = React.memo<HostItemProps>(
|
||||
({ knownHost, converted, viewMode, onDelete, onConvertToHost }) => {
|
||||
({ knownHost, converted, viewMode, reorderProps, onDelete, onConvertToHost }) => {
|
||||
const { t } = useI18n();
|
||||
// Disabled to reduce log noise - uncomment for debugging
|
||||
// console.log('[HostItem] render:', knownHost.hostname);
|
||||
@@ -128,9 +133,12 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group cursor-pointer soft-card elevate rounded-xl h-[68px] px-3 py-2",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
{/* Quick action buttons on hover */}
|
||||
@@ -167,9 +175,10 @@ const HostItem = React.memo<HostItemProps>(
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
@@ -200,14 +209,18 @@ const HostItem = React.memo<HostItemProps>(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
{...reorderProps}
|
||||
className={cn(
|
||||
reorderProps && "vault-drop-indicator-row",
|
||||
"group flex items-center gap-3 px-3 py-2 h-14 rounded-lg hover:bg-secondary/60 transition-colors cursor-pointer",
|
||||
converted && "opacity-60",
|
||||
reorderProps?.className,
|
||||
)}
|
||||
>
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Server size={18} />}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate block">
|
||||
{knownHost.hostname}
|
||||
@@ -260,6 +273,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
hosts,
|
||||
onSave: _onSave,
|
||||
onUpdate: _onUpdate,
|
||||
onReorder,
|
||||
onDelete,
|
||||
onConvertToHost,
|
||||
onImportFromFile,
|
||||
@@ -274,9 +288,10 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("newest");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("manual");
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const hasScannedRef = React.useRef(false);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
@@ -378,12 +393,14 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
return b.discoveredAt - a.discoveredAt;
|
||||
case "oldest":
|
||||
return a.discoveredAt - b.discoveredAt;
|
||||
case "manual":
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
return sortMode === "manual" ? sortByVaultOrder(result) : result;
|
||||
}, [knownHosts, deferredSearch, sortMode]);
|
||||
|
||||
// Limit rendered items for performance
|
||||
@@ -458,6 +475,18 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
const openFilePicker = useCallback(() => fileInputRef.current?.click(), []);
|
||||
|
||||
const knownHostReorder = useVaultItemReorder({
|
||||
containerRef: listRef,
|
||||
viewMode,
|
||||
dragType: "known-host-id",
|
||||
targetAttribute: "data-known-host-id",
|
||||
disabled: deferredSearch.trim().length > 0,
|
||||
onReorder: (sourceId, targetId, position) => {
|
||||
onReorder(reorderVaultItems(knownHosts, sourceId, targetId, position));
|
||||
setSortMode("manual");
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize the rendered list to prevent re-renders
|
||||
const renderedItems = useMemo(() => {
|
||||
return displayedHosts.map((knownHost) => (
|
||||
@@ -466,6 +495,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
knownHost={knownHost}
|
||||
converted={convertedMap.get(knownHost.id) || false}
|
||||
viewMode={viewMode}
|
||||
reorderProps={knownHostReorder.getItemReorderProps(knownHost.id, `known:${knownHost.id}`)}
|
||||
onDelete={handleDelete}
|
||||
onConvertToHost={handleConvertToHost}
|
||||
/>
|
||||
@@ -476,6 +506,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
viewMode,
|
||||
handleDelete,
|
||||
handleConvertToHost,
|
||||
knownHostReorder,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -524,6 +555,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
modes={["manual", "az", "za", "newest", "oldest"]}
|
||||
className={vaultHeaderIconButtonClass}
|
||||
/>
|
||||
</div>
|
||||
@@ -562,12 +594,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
"p-4",
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOverCapture={knownHostReorder.handleDragOverCapture}
|
||||
onDragOver={knownHostReorder.handleDragOver}
|
||||
onDropCapture={knownHostReorder.handleDropCapture}
|
||||
onDragEndCapture={knownHostReorder.handleDragEndCapture}
|
||||
>
|
||||
{displayedHosts.length === 0 ? (
|
||||
<div
|
||||
|
||||