Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a876fd67d | ||
|
|
d39cd60863 | ||
|
|
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 | ||
|
|
517cbb6cee | ||
|
|
3bc373dbec | ||
|
|
273fe10296 | ||
|
|
2a10a28cc8 | ||
|
|
f74645e1a4 |
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
|
||||
|
||||
33
App.tsx
@@ -138,7 +138,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
reapplyCurrentTheme,
|
||||
applyAppTheme,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
@@ -244,6 +244,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
@@ -267,12 +268,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 +291,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 +697,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).
|
||||
@@ -959,20 +972,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, 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, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,17 @@
|
||||
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';
|
||||
|
||||
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 +71,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 +90,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,
|
||||
@@ -708,6 +716,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 +735,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,
|
||||
|
||||
64
application/app/AppHostTreeLayer.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
shouldAutoOpenHostTreeOnSurfaceChange,
|
||||
} = 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 auto-opens when entering a work tab surface', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: true,
|
||||
previousSurfaceVisible: false,
|
||||
surfaceVisible: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('shared host tree does not force reopen while already on work tab surfaces', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: true,
|
||||
previousSurfaceVisible: true,
|
||||
surfaceVisible: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('shared host tree does not auto-open when disabled', () => {
|
||||
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled: false,
|
||||
previousSurfaceVisible: false,
|
||||
surfaceVisible: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('host tree layer hides immediately when leaving work tab surfaces', () => {
|
||||
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
|
||||
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
|
||||
});
|
||||
124
application/app/AppHostTreeLayer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
import type { EditorTab } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { scheduleAfterInstantThemeSwitch } from '../state/useActiveChromeTheme';
|
||||
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
|
||||
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
|
||||
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
|
||||
import {
|
||||
isHostTreeWorkTabSurface,
|
||||
resolveWorkTabActiveHostId,
|
||||
} from './workTabSurface';
|
||||
|
||||
interface AppHostTreeLayerProps {
|
||||
enabled: boolean;
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
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 function shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled,
|
||||
previousSurfaceVisible,
|
||||
surfaceVisible,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
previousSurfaceVisible: boolean;
|
||||
surfaceVisible: boolean;
|
||||
}): boolean {
|
||||
return enabled && surfaceVisible && !previousSurfaceVisible;
|
||||
}
|
||||
|
||||
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
|
||||
enabled,
|
||||
hosts,
|
||||
customGroups,
|
||||
sessions,
|
||||
workspaces,
|
||||
editorTabs,
|
||||
logViews,
|
||||
orderedTabs,
|
||||
resolvedPreviewTheme,
|
||||
onConnect,
|
||||
onCreateLocalTerminal,
|
||||
}) => {
|
||||
const activeTabId = useActiveTabId();
|
||||
const previousSurfaceVisibleRef = useRef(false);
|
||||
const cancelAutoOpenRef = useRef<(() => void) | null>(null);
|
||||
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,
|
||||
});
|
||||
useEffect(() => {
|
||||
cancelAutoOpenRef.current?.();
|
||||
cancelAutoOpenRef.current = null;
|
||||
|
||||
const previousSurfaceVisible = previousSurfaceVisibleRef.current;
|
||||
previousSurfaceVisibleRef.current = surfaceVisible;
|
||||
if (shouldAutoOpenHostTreeOnSurfaceChange({
|
||||
enabled,
|
||||
previousSurfaceVisible,
|
||||
surfaceVisible,
|
||||
})) {
|
||||
cancelAutoOpenRef.current = scheduleAfterInstantThemeSwitch(() => {
|
||||
cancelAutoOpenRef.current = null;
|
||||
terminalHostTreeStore.setIsOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAutoOpenRef.current?.();
|
||||
cancelAutoOpenRef.current = null;
|
||||
};
|
||||
}, [enabled, surfaceVisible]);
|
||||
|
||||
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}
|
||||
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,7 +42,7 @@ 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,
|
||||
@@ -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,31 @@ 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}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
editorTabs={editorTabs}
|
||||
logViews={logViews}
|
||||
orderedTabs={orderedTabsWithEditors}
|
||||
resolvedPreviewTheme={currentTerminalTheme}
|
||||
onConnect={handleConnectToHost}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
/>
|
||||
|
||||
<VaultViewContainer>
|
||||
<VaultView
|
||||
hosts={hosts}
|
||||
@@ -289,6 +295,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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -243,6 +243,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',
|
||||
|
||||
@@ -225,6 +225,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 +266,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.',
|
||||
'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/* 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',
|
||||
@@ -667,6 +669,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.',
|
||||
|
||||
|
||||
@@ -21,6 +21,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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -225,6 +225,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 +266,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.',
|
||||
'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/* Рамка вокруг боковой панели 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': 'Шрифт интерфейса',
|
||||
|
||||
@@ -42,6 +42,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': 'Кодировка терминала',
|
||||
|
||||
@@ -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': 'Папка',
|
||||
|
||||
@@ -243,6 +243,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 助手',
|
||||
|
||||
@@ -209,6 +209,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 +250,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。',
|
||||
'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/* 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 +443,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 +544,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 +575,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': '文件夹',
|
||||
|
||||
@@ -229,6 +229,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': '终端编码',
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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,7 @@ 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;
|
||||
|
||||
// Editor defaults
|
||||
export const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -129,11 +130,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,7 @@ 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_TERM_FOLLOW_APP_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
@@ -75,6 +76,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
editorWordWrap: boolean;
|
||||
sessionLogsEnabled: boolean;
|
||||
sessionLogsDir: string;
|
||||
@@ -109,6 +111,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSessionLogsDir: Dispatch<SetStateAction<string>>;
|
||||
@@ -130,7 +133,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -139,7 +142,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -153,7 +156,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
@@ -163,7 +166,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
@@ -371,6 +374,12 @@ 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);
|
||||
}
|
||||
}
|
||||
// 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,6 +445,7 @@ export function useSettingsStorageSync({
|
||||
setSftpTransferConcurrencyState,
|
||||
setSftpUseCompressedUpload,
|
||||
setShowOnlyUngroupedHostsInRootState,
|
||||
setShowHostTreeSidebarState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTerminalFontFamilyId,
|
||||
|
||||
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();
|
||||
};
|
||||
}
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ SplitDirection,
|
||||
SplitHint,
|
||||
updateWorkspaceSplitSizes,
|
||||
} from '../../domain/workspace';
|
||||
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
|
||||
import { activeTabStore } from './activeTabStore';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
@@ -857,31 +858,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 +916,7 @@ export const useSessionState = () => {
|
||||
|
||||
return currentOrder;
|
||||
});
|
||||
}, [orphanSessions, workspaces, logViews]);
|
||||
}, [baseWorkTabIds]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@@ -958,6 +961,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,7 @@ 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,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
@@ -83,6 +86,7 @@ import {
|
||||
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
@@ -104,6 +108,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 +234,10 @@ 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 [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
|
||||
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
|
||||
@@ -441,7 +450,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 +534,8 @@ 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);
|
||||
|
||||
// Workspace focus style
|
||||
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
|
||||
@@ -534,7 +547,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 +626,7 @@ export const useSettingsState = () => {
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setShowHostTreeSidebarState,
|
||||
setSftpTransferConcurrencyState,
|
||||
});
|
||||
|
||||
@@ -634,7 +653,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
@@ -643,7 +662,7 @@ export const useSettingsState = () => {
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
@@ -750,16 +769,16 @@ 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]);
|
||||
|
||||
// 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 +942,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 +1012,8 @@ export const useSettingsState = () => {
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
sftpTransferConcurrency,
|
||||
setSftpTransferConcurrency,
|
||||
// Editor Settings
|
||||
@@ -1027,7 +1047,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 +1058,7 @@ export const useSettingsState = () => {
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -170,10 +170,10 @@ export const useTerminalBackend = () => {
|
||||
return bridge.listSerialPorts();
|
||||
}, []);
|
||||
|
||||
const getSessionPwd = useCallback(async (sessionId: string) => {
|
||||
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) => {
|
||||
|
||||
@@ -4,12 +4,16 @@ import type { Host } from '../../types';
|
||||
|
||||
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;
|
||||
managedGroupPaths?: Set<string>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +63,7 @@ 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_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
@@ -404,6 +405,8 @@ 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 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;
|
||||
@@ -524,7 +527,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 +537,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
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 |
@@ -9,6 +9,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';
|
||||
@@ -111,138 +116,163 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
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}
|
||||
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);
|
||||
});
|
||||
@@ -49,13 +49,43 @@ import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
|
||||
import { generateId, isCopilotAgentConfig, modelPresetsContainId } 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);
|
||||
}
|
||||
|
||||
@@ -146,12 +176,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],
|
||||
);
|
||||
@@ -877,52 +910,54 @@ 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}
|
||||
addSelectedUserSkill={addSelectedUserSkill}
|
||||
removeSelectedUserSkill={removeSelectedUserSkill}
|
||||
globalPermissionMode={globalPermissionMode}
|
||||
setGlobalPermissionMode={setGlobalPermissionMode}
|
||||
/>
|
||||
</React.Profiler>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -992,14 +1027,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';
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
@@ -171,7 +171,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
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>
|
||||
@@ -182,8 +188,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
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();
|
||||
@@ -213,8 +225,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 +375,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost: _onDuplicateHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
@@ -390,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
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 +433,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 +471,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostTreeHostContextMenuContent
|
||||
host={host}
|
||||
onConnect={onConnect}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onDeleteHost={onDeleteHost}
|
||||
/>
|
||||
@@ -491,6 +511,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);
|
||||
@@ -562,7 +596,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">
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
VaultHeaderSearch,
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
@@ -678,7 +679,7 @@ echo $3 >> "$FILE"`);
|
||||
{/* 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">
|
||||
@@ -743,7 +744,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">
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
knownHosts: KnownHost[];
|
||||
@@ -167,9 +168,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}
|
||||
@@ -205,9 +207,10 @@ const HostItem = React.memo<HostItemProps>(
|
||||
converted && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -247,34 +247,34 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
|
||||
isLocal
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "bg-blue-500/10 text-blue-500"
|
||||
)}
|
||||
>
|
||||
<FileText size={16} />
|
||||
<FileText size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<div className="min-w-0 text-sm font-medium leading-none truncate">
|
||||
{isLocal ? t("logs.localTerminal") : log.hostname}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs leading-none text-muted-foreground truncate">
|
||||
{formattedDate} • {log.localUsername}@{log.localHostname}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 shrink-0 items-center gap-1.5">
|
||||
{/* Export button */}
|
||||
{log.terminalData && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
@@ -287,18 +287,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
className="gap-1.5 h-7 px-2 text-xs"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
|
||||
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
|
||||
{t("logView.readOnly")}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X size={16} />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
|
||||
// Import components and utilities from port-forwarding module
|
||||
@@ -690,9 +691,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</VaultPageHeader>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!hasRules ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<div className="flex h-full flex-col items-center justify-center p-3 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Zap size={32} className="opacity-60" />
|
||||
</div>
|
||||
@@ -704,9 +705,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">{t("pf.title")}</h2>
|
||||
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("pf.rulesCount", { count: filteredRules.length })}
|
||||
</span>
|
||||
|
||||
@@ -59,7 +59,14 @@ import {
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from "./vault/VaultPageHeader";
|
||||
import {
|
||||
VaultEntityIcon,
|
||||
vaultProxyCommandIconClass,
|
||||
vaultProxyHttpIconClass,
|
||||
vaultProxySocksIconClass,
|
||||
} from "./vault/VaultEntityIcon";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
@@ -99,17 +106,17 @@ const proxyProtocolMeta = {
|
||||
http: {
|
||||
label: "HTTP",
|
||||
Icon: Globe,
|
||||
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
iconClassName: vaultProxyHttpIconClass,
|
||||
},
|
||||
socks5: {
|
||||
label: "SOCKS5",
|
||||
Icon: Route,
|
||||
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
iconClassName: vaultProxySocksIconClass,
|
||||
},
|
||||
command: {
|
||||
labelKey: "hostDetails.proxyPanel.command",
|
||||
Icon: SquareTerminal,
|
||||
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
|
||||
iconClassName: vaultProxyCommandIconClass,
|
||||
},
|
||||
} satisfies Record<ProxyConfig["type"], {
|
||||
label?: string;
|
||||
@@ -163,15 +170,11 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
protocol.iconClassName,
|
||||
)}
|
||||
<VaultEntityIcon
|
||||
className={protocol.iconClassName}
|
||||
title={protocolLabel}
|
||||
>
|
||||
<ProtocolIcon size={18} />
|
||||
</div>
|
||||
icon={<ProtocolIcon size={18} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
@@ -397,7 +400,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<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("proxyProfiles.section.proxies")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -46,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
const [label, setLabel] = useState('');
|
||||
const [command, setCommand] = useState('');
|
||||
const [packagePath, setPackagePath] = useState('');
|
||||
const [noAutoRun, setNoAutoRun] = useState(false);
|
||||
const [editing, setEditing] = useState<Snippet | null>(null);
|
||||
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -58,6 +59,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setLabel('');
|
||||
setCommand('');
|
||||
setPackagePath('');
|
||||
setNoAutoRun(false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:add', handler);
|
||||
@@ -75,6 +77,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
setLabel(snippet.label ?? '');
|
||||
setCommand(snippet.command ?? '');
|
||||
setPackagePath(snippet.package ?? '');
|
||||
setNoAutoRun(snippet.noAutoRun ?? false);
|
||||
setOpen(true);
|
||||
};
|
||||
window.addEventListener('netcatty:snippets:edit', handler);
|
||||
@@ -121,6 +124,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
label: label.trim(),
|
||||
command,
|
||||
package: trimmedPackage || '',
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
});
|
||||
} else {
|
||||
onCreateSnippet({
|
||||
@@ -130,10 +134,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
tags: [],
|
||||
package: trimmedPackage || '',
|
||||
targets: [],
|
||||
noAutoRun: noAutoRun || undefined,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
|
||||
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -199,6 +204,16 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
|
||||
createText={t('snippets.field.createPackage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noAutoRun}
|
||||
onChange={(e) => setNoAutoRun(e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
|
||||
@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
|
||||
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
|
||||
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
|
||||
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
|
||||
import SettingsAITab from "./settings/tabs/SettingsAITab";
|
||||
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
|
||||
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const settingsTabTriggerClassName =
|
||||
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
|
||||
const settingsTabIconClassName = "shrink-0";
|
||||
const settingsTabLabelClassName = "min-w-0 truncate";
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
type TerminalTabSettingsProps = Pick<
|
||||
SettingsState,
|
||||
| 'terminalThemeId'
|
||||
| 'setTerminalThemeId'
|
||||
| 'followAppTerminalTheme'
|
||||
| 'setFollowAppTerminalTheme'
|
||||
| 'terminalThemeDarkId'
|
||||
| 'setTerminalThemeDarkId'
|
||||
| 'terminalThemeLightId'
|
||||
| 'setTerminalThemeLightId'
|
||||
| 'lightUiThemeId'
|
||||
| 'darkUiThemeId'
|
||||
| 'terminalFontFamilyId'
|
||||
| 'setTerminalFontFamilyId'
|
||||
| 'terminalFontSize'
|
||||
| 'setTerminalFontSize'
|
||||
| 'terminalSettings'
|
||||
| 'updateTerminalSetting'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
terminalThemeId,
|
||||
setTerminalThemeId,
|
||||
followAppTerminalTheme,
|
||||
setFollowAppTerminalTheme,
|
||||
terminalThemeDarkId,
|
||||
setTerminalThemeDarkId,
|
||||
terminalThemeLightId,
|
||||
setTerminalThemeLightId,
|
||||
lightUiThemeId,
|
||||
darkUiThemeId,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
updateTerminalSetting,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
terminalThemeId={terminalThemeId}
|
||||
setTerminalThemeId={setTerminalThemeId}
|
||||
followAppTerminalTheme={followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={setTerminalThemeDarkId}
|
||||
terminalThemeLightId={terminalThemeLightId}
|
||||
setTerminalThemeLightId={setTerminalThemeLightId}
|
||||
lightUiThemeId={lightUiThemeId}
|
||||
darkUiThemeId={darkUiThemeId}
|
||||
terminalFontFamilyId={terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={setTerminalFontFamilyId}
|
||||
terminalFontSize={terminalFontSize}
|
||||
setTerminalFontSize={setTerminalFontSize}
|
||||
terminalSettings={terminalSettings}
|
||||
updateTerminalSetting={updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -325,13 +363,34 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
/>
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
<SettingsTerminalTabContainer
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
followAppTerminalTheme={settings.followAppTerminalTheme}
|
||||
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
|
||||
terminalThemeDarkId={settings.terminalThemeDarkId}
|
||||
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
|
||||
terminalThemeLightId={settings.terminalThemeLightId}
|
||||
setTerminalThemeLightId={settings.setTerminalThemeLightId}
|
||||
lightUiThemeId={settings.lightUiThemeId}
|
||||
darkUiThemeId={settings.darkUiThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -355,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("system") && (
|
||||
|
||||
@@ -841,7 +841,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
onClick={handlePaneFocus}
|
||||
>
|
||||
{showWorkspaceHostHeader && displayHost && (
|
||||
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
|
||||
<div
|
||||
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
|
||||
data-section="terminal-sftp-host-header"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DistroAvatar
|
||||
host={displayHost}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
@@ -93,8 +92,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
VaultPageHeader,
|
||||
vaultHeaderIconButtonClass,
|
||||
vaultHeaderSecondaryButtonClass,
|
||||
vaultSectionTitleClass,
|
||||
} from './vault/VaultPageHeader';
|
||||
import {
|
||||
VaultEntityIcon,
|
||||
vaultPrimaryIconClass,
|
||||
vaultSnippetIconClass,
|
||||
} from './vault/VaultEntityIcon';
|
||||
|
||||
interface SnippetsManagerProps {
|
||||
snippets: Snippet[];
|
||||
@@ -790,7 +796,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{displayedPackages.length > 0 && !search.trim() && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
|
||||
<h3 className={vaultSectionTitleClass}>{t('snippets.section.packages')}</h3>
|
||||
</div>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
@@ -823,9 +829,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onClick={() => setSelectedPackage(pkg.path)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<Package size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultPrimaryIconClass}
|
||||
icon={<Package size={18} />}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{pkg.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
|
||||
@@ -846,7 +853,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
{displayedSnippets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.snippets')}</h3>
|
||||
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
|
||||
@@ -870,9 +877,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full min-w-0">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
|
||||
<FileCode size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultSnippetIconClass}
|
||||
icon={<FileCode size={18} />}
|
||||
/>
|
||||
<div className="w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{snippet.label}</div>
|
||||
<Tooltip>
|
||||
|
||||
@@ -179,7 +179,7 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
"h-7 w-7 relative app-no-drag top-tab-utility-btn",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
|
||||
@@ -50,7 +50,7 @@ import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTermi
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
@@ -72,6 +72,7 @@ import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
|
||||
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
import { useTerminalEffects } from "./terminal/useTerminalEffects";
|
||||
@@ -413,11 +414,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined;
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const resolveSftpInitialPath = useCallback(async (options?: { preferFreshBackend?: boolean }): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
rendererCwd: terminalCwdTracker.getRendererCwd(),
|
||||
sessionId: sessionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
});
|
||||
return cwd ?? undefined;
|
||||
}, [terminalBackend, terminalCwdTracker]);
|
||||
@@ -848,6 +850,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
scrollOnPasteRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
isLocalConnection,
|
||||
terminalBackend,
|
||||
});
|
||||
// Kept fresh on every render so the mouseTracking capture handler at
|
||||
// handleContextMenuCapture (which is bound once per sessionId) can
|
||||
@@ -1053,6 +1057,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef,
|
||||
});
|
||||
|
||||
useTerminalFilePaste({
|
||||
isLocalConnection,
|
||||
status,
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
const renderControls = useCallback((opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
@@ -1104,7 +1118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.cursor} 78%, ${effectiveTheme.colors.background} 22%))`,
|
||||
}), [effectiveTheme.colors.background, effectiveTheme.colors.cursor, effectiveTheme.colors.foreground]);
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isSupportedOs, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
@@ -136,6 +136,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsFormat,
|
||||
sessionLogsTimestampsEnabled,
|
||||
sshDebugLogsEnabled,
|
||||
showHostTreeSidebar = true,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
}) => {
|
||||
@@ -569,7 +570,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionId,
|
||||
cwdRevisionAtCommand: revisionAtCommand,
|
||||
getCwdRevision: () => terminalCwdRevisionRef.current,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
canProbe: async () => {
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
|
||||
const host = sessionHostsMapRef.current.get(sessionId);
|
||||
@@ -706,7 +707,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return resolvePreferredTerminalCwd({
|
||||
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
|
||||
sessionId,
|
||||
getSessionPwd: (id) => terminalBackend.getSessionPwd(id),
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
preferFreshBackend: options?.preferFreshBackend,
|
||||
});
|
||||
}, [getActiveTerminalSessionId, terminalBackend]);
|
||||
@@ -1108,6 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab,
|
||||
setupMcpApprovalBridge,
|
||||
showHostTreeSidebar,
|
||||
sidePanelOpenTabs,
|
||||
sidePanelPosition,
|
||||
sidePanelWidth,
|
||||
|
||||
169
components/TopTabs.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, "requestAnimationFrame", {
|
||||
configurable: true,
|
||||
value: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 0) as unknown as number,
|
||||
});
|
||||
|
||||
const {
|
||||
computeHostTreeTabGutter,
|
||||
shouldKeepHostTreeToggleSurface,
|
||||
shouldShowHostTreeToggle,
|
||||
} = await import("./TopTabs.tsx");
|
||||
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
||||
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
||||
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
||||
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("host tree tab gutter fills the remaining sidebar width", () => {
|
||||
assert.equal(computeHostTreeTabGutter(280, 120), 160);
|
||||
});
|
||||
|
||||
test("host tree tab gutter never goes negative", () => {
|
||||
assert.equal(computeHostTreeTabGutter(120, 280), 0);
|
||||
});
|
||||
|
||||
test("host tree tab surface stays mounted when root pages are active", () => {
|
||||
assert.equal(shouldKeepHostTreeToggleSurface({
|
||||
enabled: true,
|
||||
activeWorkTabCount: 2,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("host tree tab surface is hidden without work tabs", () => {
|
||||
assert.equal(shouldKeepHostTreeToggleSurface({
|
||||
enabled: true,
|
||||
activeWorkTabCount: 0,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("host tree tab layout transitions match the sidebar timing", () => {
|
||||
const hostTreeCss = [
|
||||
".top-tab-root-label",
|
||||
".top-tab-host-tree-toggle-slot",
|
||||
].map((selector) => {
|
||||
const start = indexCss.indexOf(selector);
|
||||
assert.notEqual(start, -1);
|
||||
const end = indexCss.indexOf("}", start);
|
||||
return indexCss.slice(start, end);
|
||||
}).join("\n");
|
||||
const gutterStart = indexCss.indexOf(".top-tab-host-tree-gutter");
|
||||
assert.notEqual(gutterStart, -1);
|
||||
const gutterEnd = indexCss.indexOf("}", gutterStart);
|
||||
const gutterCss = indexCss.slice(gutterStart, gutterEnd);
|
||||
|
||||
assert.match(hostTreeCss, /width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
|
||||
assert.match(hostTreeCss, /max-width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
|
||||
assert.doesNotMatch(hostTreeCss, /transition:\s*none/);
|
||||
assert.doesNotMatch(hostTreeCss, /280ms/);
|
||||
assert.doesNotMatch(gutterCss, /transition/);
|
||||
assert.match(indexCss, /\.top-tab-host-tree-gutter-exit[\s\S]*transition: width 220ms/);
|
||||
});
|
||||
|
||||
test("host tree toggle appears with opacity only and no bounce animation", () => {
|
||||
assert.doesNotMatch(indexCss, /top-tab-host-tree-toggle-pop/);
|
||||
assert.doesNotMatch(indexCss, /@keyframes\s+pop-in/);
|
||||
|
||||
const start = indexCss.indexOf(".top-tab-host-tree-toggle-slot");
|
||||
assert.notEqual(start, -1);
|
||||
const end = indexCss.indexOf("}", start);
|
||||
const toggleSlotCss = indexCss.slice(start, end);
|
||||
|
||||
assert.match(toggleSlotCss, /opacity 220ms ease/);
|
||||
assert.doesNotMatch(toggleSlotCss, /transform/);
|
||||
assert.doesNotMatch(toggleSlotCss, /scale/);
|
||||
});
|
||||
|
||||
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
|
||||
assert.match(topTabsSource, /hostTreeChromeReady/);
|
||||
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
||||
assert.match(topTabsSource, /scheduleChromeLayoutAnimation\(\(\) => \{\s*cancelRootTabsCompactRef\.current = null;\s*setRootTabsCompact\(true\);/);
|
||||
assert.match(topTabsSource, /compact=\{rootTabsCompact\}/);
|
||||
assert.match(topTabsSource, /data-visible=\{effectiveShowHostTreeToggle \? 'true' : 'false'\}/);
|
||||
});
|
||||
|
||||
test("host tree chrome exits before root labels expand back on vault", () => {
|
||||
assert.match(topTabsSource, /cancelChromeExitRef/);
|
||||
assert.match(topTabsSource, /hostTreeGutterExiting/);
|
||||
assert.match(topTabsSource, /setRootTabsCompact\(false\)/);
|
||||
assert.match(topTabsSource, /top-tab-host-tree-gutter-exit/);
|
||||
assert.match(topTabsSource, /effectiveShowHostTreeToggle = hostTreeChromeReady/);
|
||||
});
|
||||
|
||||
test("host tree toggle is shown for an active editor tab", () => {
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: true,
|
||||
activeTabId: "editor:file-1",
|
||||
orderedTabs: ["session-1", "editor:file-1"],
|
||||
sessionIds: new Set(["session-1"]),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("host tree toggle is shown for log tabs", () => {
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: true,
|
||||
activeTabId: "log-1",
|
||||
logViewIds: new Set(["log-1"]),
|
||||
orderedTabs: ["session-1", "log-1"],
|
||||
sessionIds: new Set(["session-1"]),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("host tree toggle is shown for log tabs before tab ordering catches up", () => {
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: true,
|
||||
activeTabId: "log-1",
|
||||
logViewIds: new Set(["log-1"]),
|
||||
orderedTabs: [],
|
||||
sessionIds: new Set(),
|
||||
workspaceIds: new Set(),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("clicking a log tab activates the shared work-tab surface", () => {
|
||||
activeTabStore.setActiveTabId("vault");
|
||||
|
||||
activateLogViewTab("log-1");
|
||||
|
||||
assert.equal(activeTabStore.getActiveTabId(), "log-1");
|
||||
});
|
||||
|
||||
test("host tree toggle is hidden when host sidebar is disabled", () => {
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: false,
|
||||
activeTabId: "session-1",
|
||||
orderedTabs: ["session-1"],
|
||||
sessionIds: new Set(["session-1"]),
|
||||
workspaceIds: new Set(),
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("host tree toggle is hidden on root pages", () => {
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: true,
|
||||
activeTabId: "vault",
|
||||
orderedTabs: ["session-1", "editor:file-1"],
|
||||
sessionIds: new Set(["session-1"]),
|
||||
workspaceIds: new Set(),
|
||||
}), false);
|
||||
assert.equal(shouldShowHostTreeToggle({
|
||||
enabled: true,
|
||||
activeTabId: "sftp",
|
||||
orderedTabs: ["session-1", "editor:file-1"],
|
||||
sessionIds: new Set(["session-1"]),
|
||||
workspaceIds: new Set(),
|
||||
}), false);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Folder, FolderLock, Menu, Moon, MoreHorizontal, Plus, Settings, Sparkles, Sun } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
|
||||
import type { EditorTab } from '../application/state/editorTabStore';
|
||||
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
|
||||
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
|
||||
@@ -29,16 +30,60 @@ import {
|
||||
WindowControls,
|
||||
WorkspaceTopTab,
|
||||
} from './top-tabs/TopTabItems';
|
||||
import { TERMINAL_HOST_TREE_ANIMATION_MS } from '../application/state/terminalHostTreeAnimation';
|
||||
import {
|
||||
scheduleAfterInstantThemeSwitch,
|
||||
scheduleChromeLayoutAnimation,
|
||||
} from '../application/state/useActiveChromeTheme';
|
||||
import { useTopTabLifecycleAnimations } from './top-tabs/useTopTabLifecycleAnimations';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
|
||||
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
|
||||
const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
|
||||
const emptyTabStyle: React.CSSProperties = {};
|
||||
|
||||
export function computeHostTreeTabGutter(hostTreeLayoutWidth: number, toggleRight: number): number {
|
||||
return Math.max(0, hostTreeLayoutWidth - toggleRight);
|
||||
}
|
||||
|
||||
export function shouldShowHostTreeToggle({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeTabId: string;
|
||||
logViewIds?: ReadonlySet<string>;
|
||||
orderedTabs: readonly string[];
|
||||
sessionIds: ReadonlySet<string>;
|
||||
workspaceIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return isHostTreeWorkTabSurface({
|
||||
enabled,
|
||||
activeTabId,
|
||||
logViewIds,
|
||||
orderedTabs,
|
||||
sessionIds,
|
||||
workspaceIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldKeepHostTreeToggleSurface({
|
||||
enabled,
|
||||
activeWorkTabCount,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
activeWorkTabCount: number;
|
||||
}): boolean {
|
||||
return enabled && activeWorkTabCount > 0;
|
||||
}
|
||||
|
||||
interface TopTabsProps {
|
||||
theme: 'dark' | 'light';
|
||||
followAppTerminalTheme?: boolean;
|
||||
hosts: Host[];
|
||||
sessions: TerminalSession[];
|
||||
orphanSessions: TerminalSession[];
|
||||
@@ -61,11 +106,11 @@ interface TopTabsProps {
|
||||
windowOpacity: number;
|
||||
setWindowOpacity: (opacity: number) => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
onEndSessionDrag: () => void;
|
||||
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
editorTabs: readonly EditorTab[];
|
||||
onRequestCloseEditorTab: (editorTabId: string) => void;
|
||||
hostById: Map<string, Host>;
|
||||
@@ -73,7 +118,6 @@ interface TopTabsProps {
|
||||
|
||||
const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
theme,
|
||||
followAppTerminalTheme = false,
|
||||
hosts,
|
||||
sessions,
|
||||
orphanSessions,
|
||||
@@ -96,11 +140,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onReorderTabs,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
editorTabs,
|
||||
onRequestCloseEditorTab,
|
||||
hostById,
|
||||
@@ -113,9 +157,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const toggleHostTree = useToggleTerminalHostTree();
|
||||
const activeTabId = useActiveTabId();
|
||||
const { getTabAnimationClass } = useTopTabLifecycleAnimations(orderedTabs);
|
||||
const [hostTreeTogglePop, setHostTreeTogglePop] = useState(false);
|
||||
const fixedLeftTabsRef = useRef<HTMLDivElement>(null);
|
||||
const hostTreeToggleSlotRef = useRef<HTMLDivElement>(null);
|
||||
const suppressHostTreeToggleClickRef = useRef(false);
|
||||
const hostTreeGutterCloseRafRef = useRef<number | null>(null);
|
||||
const cancelHostTreeChromeReadyRef = useRef<(() => void) | null>(null);
|
||||
const cancelRootTabsCompactRef = useRef<(() => void) | null>(null);
|
||||
const cancelChromeExitRef = useRef<(() => void) | null>(null);
|
||||
const [hostTreeTabGutter, setHostTreeTabGutter] = useState(0);
|
||||
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
|
||||
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
|
||||
const [rootTabsCompact, setRootTabsCompact] = useState(false);
|
||||
const showWindowControls = !isMacClient;
|
||||
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
@@ -220,15 +273,99 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
return counts;
|
||||
}, [sessions]);
|
||||
|
||||
const hasTerminalOrWorkspaceTabs = sessions.length > 0 || workspaces.length > 0;
|
||||
const isActiveTerminalOrWorkspaceTab = orphanSessionMap.has(activeTabId) || workspaceMap.has(activeTabId);
|
||||
const showHostTreeToggle = hasTerminalOrWorkspaceTabs && isActiveTerminalOrWorkspaceTab;
|
||||
const activeWorkTabCount = orderedTabs.length;
|
||||
const showHostTreeToggle = shouldShowHostTreeToggle({
|
||||
enabled: showHostTreeSidebar,
|
||||
activeTabId,
|
||||
logViewIds: new Set(logViewMap.keys()),
|
||||
orderedTabs,
|
||||
sessionIds: new Set(orphanSessionMap.keys()),
|
||||
workspaceIds: new Set(workspaceMap.keys()),
|
||||
});
|
||||
const hasHostTreeToggleSurface = shouldKeepHostTreeToggleSurface({
|
||||
enabled: showHostTreeSidebar,
|
||||
activeWorkTabCount,
|
||||
});
|
||||
const effectiveShowHostTreeToggle = hostTreeChromeReady;
|
||||
|
||||
const updateHostTreeTabGutter = useCallback(() => {
|
||||
if (!showHostTreeToggle || hostTreeLayoutWidth <= 0) {
|
||||
useEffect(() => {
|
||||
cancelHostTreeChromeReadyRef.current?.();
|
||||
cancelHostTreeChromeReadyRef.current = null;
|
||||
cancelRootTabsCompactRef.current?.();
|
||||
cancelRootTabsCompactRef.current = null;
|
||||
cancelChromeExitRef.current?.();
|
||||
cancelChromeExitRef.current = null;
|
||||
|
||||
if (!showHostTreeToggle) {
|
||||
if (hostTreeChromeReady) {
|
||||
setRootTabsCompact(false);
|
||||
setHostTreeGutterExiting(true);
|
||||
const gutterRaf = window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => setHostTreeTabGutter(0));
|
||||
});
|
||||
const timer = window.setTimeout(() => {
|
||||
cancelChromeExitRef.current = null;
|
||||
setHostTreeChromeReady(false);
|
||||
setHostTreeGutterExiting(false);
|
||||
}, TERMINAL_HOST_TREE_ANIMATION_MS);
|
||||
cancelChromeExitRef.current = () => {
|
||||
window.cancelAnimationFrame(gutterRaf);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
} else {
|
||||
setHostTreeChromeReady(false);
|
||||
setHostTreeGutterExiting(false);
|
||||
setRootTabsCompact(false);
|
||||
}
|
||||
return () => {
|
||||
cancelChromeExitRef.current?.();
|
||||
cancelChromeExitRef.current = null;
|
||||
};
|
||||
}
|
||||
|
||||
if (!hostTreeChromeReady) {
|
||||
cancelHostTreeChromeReadyRef.current = scheduleAfterInstantThemeSwitch(() => {
|
||||
cancelHostTreeChromeReadyRef.current = null;
|
||||
setHostTreeChromeReady(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!rootTabsCompact) {
|
||||
cancelRootTabsCompactRef.current = scheduleChromeLayoutAnimation(() => {
|
||||
cancelRootTabsCompactRef.current = null;
|
||||
setRootTabsCompact(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelHostTreeChromeReadyRef.current?.();
|
||||
cancelHostTreeChromeReadyRef.current = null;
|
||||
cancelRootTabsCompactRef.current?.();
|
||||
cancelRootTabsCompactRef.current = null;
|
||||
};
|
||||
}, [hostTreeChromeReady, rootTabsCompact, showHostTreeToggle]);
|
||||
|
||||
const updateHostTreeTabGutter = useCallback((options?: { deferClose?: boolean }) => {
|
||||
if (hostTreeGutterExiting) return;
|
||||
|
||||
if (!effectiveShowHostTreeToggle || hostTreeLayoutWidth <= 0) {
|
||||
if (!effectiveShowHostTreeToggle && options?.deferClose) {
|
||||
if (hostTreeGutterCloseRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
|
||||
}
|
||||
hostTreeGutterCloseRafRef.current = window.requestAnimationFrame(() => {
|
||||
hostTreeGutterCloseRafRef.current = null;
|
||||
setHostTreeTabGutter(0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setHostTreeTabGutter(0);
|
||||
return;
|
||||
}
|
||||
if (hostTreeGutterCloseRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
|
||||
hostTreeGutterCloseRafRef.current = null;
|
||||
}
|
||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
const toggleSlot = hostTreeToggleSlotRef.current;
|
||||
if (!root || !toggleSlot) {
|
||||
@@ -237,38 +374,46 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
const rootLeft = root.getBoundingClientRect().left;
|
||||
const toggleRight = toggleSlot.getBoundingClientRect().right - rootLeft;
|
||||
setHostTreeTabGutter(Math.max(0, hostTreeLayoutWidth - toggleRight));
|
||||
}, [hostTreeLayoutWidth, showHostTreeToggle]);
|
||||
setHostTreeTabGutter(computeHostTreeTabGutter(hostTreeLayoutWidth, toggleRight));
|
||||
}, [effectiveShowHostTreeToggle, hostTreeGutterExiting, hostTreeLayoutWidth]);
|
||||
|
||||
const updateHostTreeTabGutterRef = useRef(updateHostTreeTabGutter);
|
||||
updateHostTreeTabGutterRef.current = updateHostTreeTabGutter;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHostTreeTabGutter();
|
||||
updateHostTreeTabGutter({ deferClose: true });
|
||||
}, [hostTreeLayoutWidth, updateHostTreeTabGutter]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const syncGutter = () => updateHostTreeTabGutterRef.current();
|
||||
syncGutter({ deferClose: true });
|
||||
const rafId = window.requestAnimationFrame(() => syncGutter());
|
||||
const settleTimer = window.setTimeout(syncGutter, 320);
|
||||
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
|
||||
if (!root) return;
|
||||
const ro = new ResizeObserver(() => updateHostTreeTabGutter());
|
||||
ro.observe(root);
|
||||
const ro = new ResizeObserver(() => syncGutter());
|
||||
if (root) ro.observe(root);
|
||||
if (fixedLeftTabsRef.current) ro.observe(fixedLeftTabsRef.current);
|
||||
if (tabsContainerRef.current) ro.observe(tabsContainerRef.current);
|
||||
if (hostTreeToggleSlotRef.current) ro.observe(hostTreeToggleSlotRef.current);
|
||||
window.addEventListener('resize', updateHostTreeTabGutter);
|
||||
window.addEventListener('resize', syncGutter);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
if (hostTreeGutterCloseRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(hostTreeGutterCloseRafRef.current);
|
||||
hostTreeGutterCloseRafRef.current = null;
|
||||
}
|
||||
window.clearTimeout(settleTimer);
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', updateHostTreeTabGutter);
|
||||
window.removeEventListener('resize', syncGutter);
|
||||
};
|
||||
}, [
|
||||
updateHostTreeTabGutter,
|
||||
orderedTabs.length,
|
||||
showSftpTab,
|
||||
isWindowFullscreen,
|
||||
showHostTreeToggle,
|
||||
effectiveShowHostTreeToggle,
|
||||
isHostTreeOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showHostTreeToggle) return;
|
||||
setHostTreeTogglePop(true);
|
||||
const timer = window.setTimeout(() => setHostTreeTogglePop(false), 360);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [showHostTreeToggle]);
|
||||
|
||||
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('tab-reorder-id', tabId);
|
||||
@@ -336,6 +481,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
scrollTopTabIntoComfortView(e.currentTarget, tab, 'smooth');
|
||||
}, []);
|
||||
|
||||
const handleHostTreeTogglePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (!effectiveShowHostTreeToggle) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressHostTreeToggleClickRef.current = true;
|
||||
toggleHostTree();
|
||||
}, [effectiveShowHostTreeToggle, toggleHostTree]);
|
||||
|
||||
const handleHostTreeToggleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (suppressHostTreeToggleClickRef.current) {
|
||||
suppressHostTreeToggleClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!effectiveShowHostTreeToggle) return;
|
||||
toggleHostTree();
|
||||
}, [effectiveShowHostTreeToggle, toggleHostTree]);
|
||||
|
||||
// Pre-compute tab shift styles for all tabs to avoid recalculation during render
|
||||
const tabShiftStyles = useMemo(() => {
|
||||
if (!dropIndicator || !isDraggingForReorder || !draggedTabIdRef.current) {
|
||||
@@ -448,6 +611,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
|
||||
: '';
|
||||
|
||||
const isBeingDragged = draggingSessionId === tabId;
|
||||
const shiftStyle = tabShiftStyles[tabId] || emptyTabStyle;
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === tabId && dropIndicator.position === 'before';
|
||||
const showDropIndicatorAfter = dropIndicator?.tabId === tabId && dropIndicator.position === 'after';
|
||||
|
||||
return (
|
||||
<EditorTopTab
|
||||
key={tabId}
|
||||
@@ -456,6 +624,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
host={host}
|
||||
suffix={suffix}
|
||||
onRequestCloseEditorTab={onRequestCloseEditorTab}
|
||||
isBeingDragged={isBeingDragged}
|
||||
isDraggingForReorder={isDraggingForReorder}
|
||||
shiftStyle={shiftStyle}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
onTabDragStart={handleTabDragStart}
|
||||
onTabDragEnd={handleTabDragEnd}
|
||||
onTabDragOver={handleTabDragOver}
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
tabAnimationClass={getTabAnimationClass(tabId)}
|
||||
/>
|
||||
);
|
||||
@@ -532,12 +710,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
if (item.type === 'logView') {
|
||||
const logView = item.logView;
|
||||
const isBeingDragged = draggingSessionId === logView.id;
|
||||
const shiftStyle = tabShiftStyles[logView.id] || emptyTabStyle;
|
||||
const showDropIndicatorBefore = dropIndicator?.tabId === logView.id && dropIndicator.position === 'before';
|
||||
const showDropIndicatorAfter = dropIndicator?.tabId === logView.id && dropIndicator.position === 'after';
|
||||
|
||||
return (
|
||||
<LogViewTopTab
|
||||
key={logView.id}
|
||||
logView={logView}
|
||||
onCloseLogView={onCloseLogView}
|
||||
isBeingDragged={isBeingDragged}
|
||||
isDraggingForReorder={isDraggingForReorder}
|
||||
shiftStyle={shiftStyle}
|
||||
showDropIndicatorBefore={showDropIndicatorBefore}
|
||||
showDropIndicatorAfter={showDropIndicatorAfter}
|
||||
onTabDragStart={handleTabDragStart}
|
||||
onTabDragEnd={handleTabDragEnd}
|
||||
onTabDragOver={handleTabDragOver}
|
||||
onTabDragLeave={handleTabDragLeave}
|
||||
onTabDrop={handleTabDrop}
|
||||
t={t}
|
||||
tabAnimationClass={getTabAnimationClass(logView.id)}
|
||||
/>
|
||||
@@ -576,17 +768,21 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-9 flex items-end gap-0 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
className="h-9 flex items-end gap-0 app-drag overflow-visible"
|
||||
style={{
|
||||
...dragRegionStyle,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
|
||||
paddingRight: showWindowControls ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div ref={fixedLeftTabsRef} className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<RootTopTab
|
||||
tabId="vault"
|
||||
label="Vaults"
|
||||
icon={<FolderLock size={14} />}
|
||||
className="rounded"
|
||||
compact={showHostTreeToggle}
|
||||
compact={rootTabsCompact}
|
||||
/>
|
||||
{showSftpTab && (
|
||||
<RootTopTab
|
||||
@@ -594,7 +790,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
label="SFTP"
|
||||
icon={<Folder size={14} />}
|
||||
className="rounded-t-md"
|
||||
compact={showHostTreeToggle}
|
||||
compact={rootTabsCompact}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -612,11 +808,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasTerminalOrWorkspaceTabs && (
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
ref={hostTreeToggleSlotRef}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end"
|
||||
data-visible={showHostTreeToggle ? 'true' : 'false'}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
|
||||
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
|
||||
style={noDragRegionStyle}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -627,15 +824,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
data-state={isHostTreeOpen ? 'active' : 'inactive'}
|
||||
className={cn(
|
||||
'h-7 w-7 flex-shrink-0 app-no-drag rounded-none hover:bg-transparent',
|
||||
hostTreeTogglePop && showHostTreeToggle && 'top-tab-host-tree-toggle-pop',
|
||||
)}
|
||||
style={{
|
||||
color: isHostTreeOpen
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
pointerEvents: showHostTreeToggle ? 'auto' : 'none',
|
||||
pointerEvents: effectiveShowHostTreeToggle ? 'auto' : 'none',
|
||||
...noDragRegionStyle,
|
||||
}}
|
||||
onClick={toggleHostTree}
|
||||
onPointerDown={handleHostTreeTogglePointerDown}
|
||||
onClick={handleHostTreeToggleClick}
|
||||
>
|
||||
<Menu size={14} />
|
||||
</Button>
|
||||
@@ -646,9 +844,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{showHostTreeToggle && (
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
className="top-tab-host-tree-gutter flex-shrink-0"
|
||||
className={cn(
|
||||
'top-tab-host-tree-gutter flex-shrink-0',
|
||||
hostTreeGutterExiting && 'top-tab-host-tree-gutter-exit',
|
||||
)}
|
||||
style={{ width: hostTreeTabGutter }}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -721,9 +922,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Fixed right controls — utility icons + window controls share one row */}
|
||||
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7"
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
|
||||
style={dragRegionStyle}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -731,7 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
@@ -743,13 +944,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<WindowOpacityButton
|
||||
windowOpacity={windowOpacity}
|
||||
setWindowOpacity={setWindowOpacity}
|
||||
className="h-7 w-7 shrink-0"
|
||||
className="h-7 w-7 shrink-0 top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<SyncStatusButton
|
||||
onOpenSettings={onOpenSettings}
|
||||
onSyncNow={onSyncNow}
|
||||
className="h-7 w-7 shrink-0"
|
||||
className="h-7 w-7 shrink-0 top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<Tooltip>
|
||||
@@ -757,10 +958,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
@@ -772,7 +972,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag top-tab-utility-btn"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
@@ -781,10 +981,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isMacClient && <WindowControls />}
|
||||
{showWindowControls && <WindowControls />}
|
||||
</div>
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0 self-end" />}
|
||||
{isMacClient && !showWindowControls && (
|
||||
<div className="w-2 h-9 app-drag flex-shrink-0 self-end" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -809,9 +1011,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.setWindowOpacity === next.setWindowOpacity &&
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
prev.isImmersiveActive === next.isImmersiveActive &&
|
||||
prev.showSftpTab === next.showSftpTab
|
||||
prev.showSftpTab === next.showSftpTab &&
|
||||
prev.showHostTreeSidebar === next.showHostTreeSidebar
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import {
|
||||
ConnectionLog,
|
||||
GroupConfig,
|
||||
@@ -89,6 +88,7 @@ import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { HostTreeGroupDeleteDialog } from "./host/HostTreeGroupDeleteDialog";
|
||||
import { useHostTreeInlineGroupActions } from "./vault/useHostTreeInlineGroupActions";
|
||||
import { useHostTreeInlineHostActions } from "./vault/useHostTreeInlineHostActions";
|
||||
import { useRegisterVaultHostTreeActions } from "./vault/useRegisterVaultHostTreeActions";
|
||||
import { Button } from "./ui/button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -262,8 +262,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [storedSidebarCollapsed, setStoredSidebarCollapsed] = useStoredBoolean(
|
||||
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
||||
@@ -482,6 +480,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleDuplicateHost = useCallback((host: Host) => {
|
||||
setCurrentSection("hosts");
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
// Create a copy of the host with a new ID and modified label
|
||||
const duplicatedHost: Host = {
|
||||
...host,
|
||||
@@ -962,8 +963,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
t,
|
||||
});
|
||||
|
||||
const {
|
||||
startInlineRenameHost,
|
||||
commitInlineHostRename,
|
||||
cancelInlineHostEdit,
|
||||
} = useHostTreeInlineHostActions({
|
||||
hosts,
|
||||
onUpdateHosts,
|
||||
t,
|
||||
});
|
||||
|
||||
useRegisterVaultHostTreeActions({
|
||||
handleCopyCredentials,
|
||||
handleDuplicateHost,
|
||||
startInlineRenameHost,
|
||||
onDeleteHost,
|
||||
handleUnmanageGroup,
|
||||
moveHostToGroup,
|
||||
@@ -974,6 +987,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
startInlineDeleteGroup,
|
||||
commitInlineGroupRename,
|
||||
cancelInlineGroupEdit,
|
||||
commitInlineHostRename,
|
||||
cancelInlineHostEdit,
|
||||
});
|
||||
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
resolveApproval,
|
||||
type ApprovalRequest,
|
||||
} from '../../infrastructure/ai/shared/approvalGate';
|
||||
import {
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './aiPanelDiagnostics';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -140,6 +145,10 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
const hideAttachments = isAIPanelDiagnosticPartHidden('attachments', hiddenParts);
|
||||
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
|
||||
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
|
||||
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -201,17 +210,20 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
)}
|
||||
{displayedMessages.map((message) => {
|
||||
if (message.role === 'tool') {
|
||||
if (hideToolCalls) return null;
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
{message.toolResults?.map((tr) => (
|
||||
<div key={tr.toolCallId}>
|
||||
<ToolCall
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
</div>
|
||||
<React.Profiler key={tr.toolCallId} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Result')}>
|
||||
<div>
|
||||
<ToolCall
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -234,7 +246,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
)}
|
||||
|
||||
{/* User attachments (images, files) — fallback to legacy `images` field */}
|
||||
{isUser && (message.attachments ?? message.images)?.length && (
|
||||
{isUser && !hideAttachments && (message.attachments ?? message.images)?.length && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{(message.attachments ?? message.images)!.map((att, i) => (
|
||||
att.terminalSelection ? (
|
||||
@@ -269,16 +281,22 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{message.content && (
|
||||
isUser
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
|
||||
: <MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
: hideMarkdown
|
||||
? <div className="whitespace-pre-wrap break-words text-[13px] leading-[1.45]">{message.content}</div>
|
||||
: (
|
||||
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Markdown')}>
|
||||
<MessageResponse isAnimating={isThisStreaming}>
|
||||
{message.content}
|
||||
</MessageResponse>
|
||||
</React.Profiler>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Pending tool calls from the *last* assistant message are rendered
|
||||
after all tool-result messages (see below) for chronological order.
|
||||
Unresolved tool calls from earlier or cancelled messages are shown
|
||||
inline — as interrupted, or with approval controls if still pending. */}
|
||||
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
{!hideToolCalls && (message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
@@ -291,16 +309,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Pending')}>
|
||||
<div>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -332,7 +352,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
|
||||
{/* Pending tool calls from the last assistant message — rendered here
|
||||
(after all tool-result messages) so they appear at the bottom. */}
|
||||
{lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
{!hideToolCalls && lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
@@ -345,35 +365,39 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<div key={tc.id}>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
<React.Profiler key={tc.id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Last')}>
|
||||
<div>
|
||||
<ToolCall
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/SDK approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
{!hideToolCalls && Array.from(pendingApprovals.entries())
|
||||
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
|
||||
.map(([id, req]) => {
|
||||
return (
|
||||
<div key={id}>
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
</div>
|
||||
<React.Profiler key={id} {...getAIPanelProfilerProps('AIChatPanel.ToolCall.Approval')}>
|
||||
<div>
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
args={req.args}
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
onApprove={() => handleApprove(id)}
|
||||
onReject={() => handleReject(id)}
|
||||
/>
|
||||
</div>
|
||||
</React.Profiler>
|
||||
);
|
||||
})}
|
||||
{/* Streaming indicator — only when no content and no thinking yet */}
|
||||
|
||||
63
components/ai/aiPanelDiagnostics.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
AI_PANEL_FORCE_HIDE_ALL_CONTENT,
|
||||
AI_PANEL_FORCE_HIDE_SHELL,
|
||||
AI_PANEL_DIAGNOSTIC_HIDE_KEY,
|
||||
AI_PANEL_DIAGNOSTIC_PROFILE_KEY,
|
||||
getAIPanelDiagnosticHiddenParts,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
isAIPanelDiagnosticsProfilingEnabled,
|
||||
} = await import('./aiPanelDiagnostics.ts');
|
||||
|
||||
test('AI panel diagnostics does not hide content by default', () => {
|
||||
window.localStorage.removeItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
|
||||
assert.equal(AI_PANEL_FORCE_HIDE_ALL_CONTENT, false);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('header'), false);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('input'), false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics does not hide the side panel shell by default', () => {
|
||||
assert.equal(AI_PANEL_FORCE_HIDE_SHELL, false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics parses hidden parts from local storage', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, ' messages, input ,markdown ');
|
||||
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
assert.equal(hiddenParts.has('messages'), true);
|
||||
assert.equal(hiddenParts.has('input'), true);
|
||||
assert.equal(hiddenParts.has('markdown'), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('messages', hiddenParts), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts), false);
|
||||
});
|
||||
|
||||
test('AI panel diagnostics supports hiding everything at once', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_HIDE_KEY, 'all');
|
||||
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
||||
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('header', hiddenParts), true);
|
||||
assert.equal(isAIPanelDiagnosticPartHidden('input', hiddenParts), true);
|
||||
});
|
||||
|
||||
test('AI panel profiling accepts common enabled values', () => {
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, 'on');
|
||||
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), true);
|
||||
|
||||
window.localStorage.setItem(AI_PANEL_DIAGNOSTIC_PROFILE_KEY, '0');
|
||||
assert.equal(isAIPanelDiagnosticsProfilingEnabled(), false);
|
||||
});
|
||||
81
components/ai/aiPanelDiagnostics.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type React from 'react';
|
||||
|
||||
export const AI_PANEL_DIAGNOSTIC_HIDE_KEY = 'netcatty.aiDebug.hide';
|
||||
export const AI_PANEL_DIAGNOSTIC_PROFILE_KEY = 'netcatty.aiDebug.profile';
|
||||
export const AI_PANEL_FORCE_HIDE_ALL_CONTENT = false;
|
||||
export const AI_PANEL_FORCE_HIDE_SHELL = false;
|
||||
|
||||
export type AIPanelDiagnosticPart =
|
||||
| 'all'
|
||||
| 'attachments'
|
||||
| 'header'
|
||||
| 'history'
|
||||
| 'input'
|
||||
| 'markdown'
|
||||
| 'messages'
|
||||
| 'recent'
|
||||
| 'toolcalls';
|
||||
|
||||
function readLocalStorageValue(key: string): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
try {
|
||||
return window.localStorage.getItem(key) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelDiagnosticHiddenParts(): ReadonlySet<string> {
|
||||
if (AI_PANEL_FORCE_HIDE_ALL_CONTENT) {
|
||||
return new Set(['all']);
|
||||
}
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_HIDE_KEY);
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticPartHidden(
|
||||
part: AIPanelDiagnosticPart,
|
||||
hiddenParts = getAIPanelDiagnosticHiddenParts(),
|
||||
): boolean {
|
||||
return hiddenParts.has('all') || hiddenParts.has(part);
|
||||
}
|
||||
|
||||
export function isAIPanelDiagnosticsProfilingEnabled(): boolean {
|
||||
const raw = readLocalStorageValue(AI_PANEL_DIAGNOSTIC_PROFILE_KEY).trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
||||
}
|
||||
|
||||
export function logAIPanelProfiler(
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
): void {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return;
|
||||
console.info(
|
||||
`[AI panel profile] ${id} ${phase}: actual=${actualDuration.toFixed(1)}ms base=${baseDuration.toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
export function profileAIPanelCalculation<T>(label: string, calculate: () => T): T {
|
||||
if (!isAIPanelDiagnosticsProfilingEnabled()) return calculate();
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return calculate();
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
console.info(`[AI panel profile] ${label}: ${elapsed.toFixed(1)}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAIPanelProfilerProps(id: string): Pick<React.ProfilerProps, 'id' | 'onRender'> {
|
||||
return {
|
||||
id,
|
||||
onRender: logAIPanelProfiler,
|
||||
};
|
||||
}
|
||||
152
components/ai/cattyHistoryReplay.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildHistoricalToolReplayMaps,
|
||||
buildHistoricalToolResultReplayText,
|
||||
buildHistoricalUserReplayContent,
|
||||
} from "./cattyHistoryReplay.ts";
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical image data with a placeholder", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "A".repeat(100_000),
|
||||
mediaType: "image/png",
|
||||
filename: "screenshot.png",
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("inspect this", [attachment]);
|
||||
|
||||
assert.match(result, /inspect this/);
|
||||
assert.match(result, /Historical image attachment omitted from replay/);
|
||||
assert.match(result, /filename=screenshot\.png/);
|
||||
assert.doesNotMatch(result, /AAAAA/);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent preserves historical file path metadata", () => {
|
||||
const content = buildHistoricalUserReplayContent("inspect this file", [{
|
||||
base64Data: "A".repeat(200),
|
||||
mediaType: "text/plain",
|
||||
filename: "deploy.log",
|
||||
filePath: "/tmp/netcatty/deploy.log",
|
||||
}]);
|
||||
|
||||
assert.match(content, /Historical file attachment omitted from replay/);
|
||||
assert.match(content, /filename=deploy\.log/);
|
||||
assert.match(content, /path=\/tmp\/netcatty\/deploy\.log/);
|
||||
assert.doesNotMatch(content, /AAAAAAAA/);
|
||||
});
|
||||
|
||||
test("buildHistoricalUserReplayContent replaces historical terminal selections with metadata only", () => {
|
||||
const attachment: ChatMessageAttachment = {
|
||||
base64Data: "VGhpcyBpcyBhIGxvbmcgdGVybWluYWwgc2VsZWN0aW9u",
|
||||
mediaType: "text/plain",
|
||||
filename: "terminal-selection.log",
|
||||
terminalSelection: true,
|
||||
previewText: "npm run build failed on vite",
|
||||
lineCount: 42,
|
||||
};
|
||||
|
||||
const result = buildHistoricalUserReplayContent("", [attachment]);
|
||||
|
||||
assert.match(result, /Historical terminal selection omitted from replay/);
|
||||
assert.match(result, /filename=terminal-selection\.log/);
|
||||
assert.match(result, /lines=42/);
|
||||
assert.match(result, /preview=npm run build failed on vite/);
|
||||
assert.doesNotMatch(result, /long terminal selection/);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText replaces historical terminal output with a replay placeholder", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "npm run build" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "BUILD ".repeat(20_000),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
const replay = buildHistoricalToolResultReplayText(result, toolCall);
|
||||
|
||||
assert.match(replay, /Historical terminal output omitted from replay/);
|
||||
assert.match(replay, /command=npm run build/);
|
||||
assert.match(replay, /status=error/);
|
||||
assert.doesNotMatch(replay, /BUILD BUILD BUILD/);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText keeps non-terminal tool results intact", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "web_search",
|
||||
arguments: { query: "Vercel AI SDK" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "search result summary",
|
||||
};
|
||||
|
||||
assert.equal(buildHistoricalToolResultReplayText(result, toolCall), "search result summary");
|
||||
});
|
||||
|
||||
test("buildHistoricalToolResultReplayText can preserve terminal output for 413 retries", () => {
|
||||
const toolCall: ToolCall = {
|
||||
id: "call-1",
|
||||
name: "terminal_execute",
|
||||
arguments: { command: "npm test" },
|
||||
};
|
||||
const result: ToolResult = {
|
||||
toolCallId: "call-1",
|
||||
content: "real terminal output",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
buildHistoricalToolResultReplayText(result, toolCall, { preserveTerminalOutput: true }),
|
||||
"real terminal output",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildHistoricalToolReplayMaps pairs reused tool ids with the nearest preceding call", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 1,
|
||||
toolCalls: [{ id: "call1", name: "url_fetch", arguments: { url: "https://example.com" } }],
|
||||
},
|
||||
{
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: "call1", content: "PAGE" }],
|
||||
},
|
||||
{
|
||||
id: "assistant-2",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: 3,
|
||||
toolCalls: [{ id: "call1", name: "terminal_execute", arguments: { command: "cat /tmp/log" } }],
|
||||
},
|
||||
{
|
||||
id: "tool-2",
|
||||
role: "tool",
|
||||
content: "",
|
||||
timestamp: 4,
|
||||
toolResults: [{ toolCallId: "call1", content: "TERMINAL BYTES" }],
|
||||
},
|
||||
];
|
||||
|
||||
const maps = buildHistoricalToolReplayMaps(messages);
|
||||
const secondResult = messages[3].toolResults?.[0];
|
||||
assert.ok(secondResult);
|
||||
const pairedCall = maps.toolCallByToolResult.get(secondResult);
|
||||
|
||||
assert.equal(pairedCall?.name, "terminal_execute");
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[0])?.has(messages[0].toolCalls![0]), true);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[1]), undefined);
|
||||
assert.equal(maps.resolvedToolCallsByAssistant.get(messages[2])?.has(messages[2].toolCalls![0]), true);
|
||||
});
|
||||
138
components/ai/cattyHistoryReplay.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from "../../infrastructure/ai/types";
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
const MAX_TOOL_COMMAND_CHARS = 220;
|
||||
|
||||
function truncateInline(value: string, maxChars: number): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
|
||||
return `${attachment.base64Data.length} base64 chars`;
|
||||
}
|
||||
|
||||
function formatTerminalSelectionPlaceholder(
|
||||
attachment: ChatMessageAttachment,
|
||||
index: number,
|
||||
): string {
|
||||
const details = [
|
||||
`filename=${attachment.filename || `terminal-selection-${index + 1}.log`}`,
|
||||
attachment.lineCount != null ? `lines=${attachment.lineCount}` : undefined,
|
||||
attachment.previewText ? `preview=${truncateInline(attachment.previewText, MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS)}` : undefined,
|
||||
describeAttachmentSize(attachment),
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
return `[Historical terminal selection omitted from replay: ${details}]`;
|
||||
}
|
||||
|
||||
function formatAttachmentPlaceholder(
|
||||
attachment: ChatMessageAttachment,
|
||||
index: number,
|
||||
): string {
|
||||
const label = attachment.mediaType.startsWith("image/") ? "image" : "file";
|
||||
const details = [
|
||||
attachment.filename ? `filename=${attachment.filename}` : undefined,
|
||||
attachment.filePath ? `path=${attachment.filePath}` : undefined,
|
||||
`mediaType=${attachment.mediaType}`,
|
||||
describeAttachmentSize(attachment),
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
return `[Historical ${label} attachment omitted from replay: ${details || `attachment-${index + 1}`}]`;
|
||||
}
|
||||
|
||||
export function buildHistoricalUserReplayContent(
|
||||
content: string,
|
||||
attachments: ChatMessageAttachment[] = [],
|
||||
): string {
|
||||
const placeholders = attachments.map((attachment, index) => (
|
||||
isTerminalSelectionAttachment(attachment)
|
||||
? formatTerminalSelectionPlaceholder(attachment, index)
|
||||
: formatAttachmentPlaceholder(attachment, index)
|
||||
));
|
||||
|
||||
if (!placeholders.length) return content;
|
||||
const attachmentBlock = placeholders.map((line) => `\n\n${line}`).join("");
|
||||
return content.trim() ? `${content}${attachmentBlock}` : placeholders.join("\n\n");
|
||||
}
|
||||
|
||||
function getToolCommand(toolCall?: ToolCall): string | undefined {
|
||||
const args = toolCall?.arguments ?? {};
|
||||
if (typeof args.command === "string") return args.command;
|
||||
const serialized = JSON.stringify(args);
|
||||
return serialized && serialized !== "{}" ? serialized : undefined;
|
||||
}
|
||||
|
||||
export function buildHistoricalToolReplayMaps(messages: ChatMessage[]): {
|
||||
resolvedToolCallsByAssistant: Map<ChatMessage, Set<ToolCall>>;
|
||||
toolCallByToolResult: Map<ToolResult, ToolCall>;
|
||||
} {
|
||||
const resolvedToolCallsByAssistant = new Map<ChatMessage, Set<ToolCall>>();
|
||||
const toolCallByToolResult = new Map<ToolResult, ToolCall>();
|
||||
const pendingToolCalls: Array<{ message: ChatMessage; toolCall: ToolCall }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
pendingToolCalls.push({ message, toolCall });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role !== "tool" || !message.toolResults?.length) continue;
|
||||
|
||||
for (const result of message.toolResults) {
|
||||
const pendingIndex = findLastIndex(
|
||||
pendingToolCalls,
|
||||
({ toolCall }) => toolCall.id === result.toolCallId,
|
||||
);
|
||||
if (pendingIndex < 0) continue;
|
||||
|
||||
const [paired] = pendingToolCalls.splice(pendingIndex, 1);
|
||||
toolCallByToolResult.set(result, paired.toolCall);
|
||||
|
||||
const resolved = resolvedToolCallsByAssistant.get(paired.message) ?? new Set<ToolCall>();
|
||||
resolved.add(paired.toolCall);
|
||||
resolvedToolCallsByAssistant.set(paired.message, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolvedToolCallsByAssistant, toolCallByToolResult };
|
||||
}
|
||||
|
||||
function findLastIndex<T>(items: T[], predicate: (item: T) => boolean): number {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (predicate(items[index])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function buildHistoricalToolResultReplayText(
|
||||
result: ToolResult,
|
||||
toolCall?: ToolCall,
|
||||
{
|
||||
preserveTerminalOutput = false,
|
||||
}: {
|
||||
preserveTerminalOutput?: boolean;
|
||||
} = {},
|
||||
): string {
|
||||
const toolName = toolCall?.name ?? "unknown";
|
||||
if (!isTerminalToolName(toolName) || preserveTerminalOutput) {
|
||||
return result.content;
|
||||
}
|
||||
|
||||
const details = [
|
||||
`toolCallId=${result.toolCallId}`,
|
||||
getToolCommand(toolCall) ? `command=${truncateInline(getToolCommand(toolCall) ?? "", MAX_TOOL_COMMAND_CHARS)}` : undefined,
|
||||
`outputChars=${result.content.length}`,
|
||||
result.isError ? "status=error" : "status=success",
|
||||
].filter(Boolean).join(", ");
|
||||
|
||||
return `[Historical terminal output omitted from replay: ${details}. Re-run terminal_execute if exact output is needed.]`;
|
||||
}
|
||||
|
||||
function isTerminalToolName(toolName: string): boolean {
|
||||
return toolName === "terminal" || toolName === "terminal_exec" || toolName === "terminal_execute";
|
||||
}
|
||||
@@ -75,7 +75,7 @@ test("buildExternalAgentHistoryMessagesForBridge keeps fallback history availabl
|
||||
);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages expands terminal selection attachments", () => {
|
||||
test("buildExternalAgentHistoryMessages replaces historical terminal selection attachments with placeholders", () => {
|
||||
const terminalSelection = createTerminalSelectionAttachment("docker ps -a\npermission denied");
|
||||
assert.ok(terminalSelection);
|
||||
const messages: ChatMessage[] = [
|
||||
@@ -88,9 +88,9 @@ test("buildExternalAgentHistoryMessages expands terminal selection attachments",
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /\[Terminal selection:/);
|
||||
assert.match(result[0].content, /Historical terminal selection omitted from replay/);
|
||||
assert.match(result[0].content, /docker ps -a/);
|
||||
assert.match(result[0].content, /permission denied/);
|
||||
assert.doesNotMatch(result[0].content, /permission denied/);
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
@@ -292,12 +292,9 @@ test("buildExternalAgentHistoryMessages still drops one-word filler user message
|
||||
}
|
||||
});
|
||||
|
||||
test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
test("buildExternalAgentHistoryMessages replaces recent terminal tool output with a replay placeholder", () => {
|
||||
// Historical terminal output should remain self-describing without
|
||||
// replaying the actual bytes on every follow-up.
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
@@ -315,16 +312,15 @@ test("buildExternalAgentHistoryMessages preserves recent tool results verbatim (
|
||||
const result = buildExternalAgentHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): \[Historical terminal output omitted from replay/);
|
||||
assert.match(flat, /outputChars=1500/);
|
||||
assert.doesNotMatch(flat, /DATA DATA DATA/);
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
toolResultChunk.length < 500,
|
||||
`expected terminal result placeholder to stay compact, got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -650,8 +646,10 @@ test("buildExternalAgentHistoryMessages resolves tool_call provenance correctly
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/hosts/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*Historical terminal output omitted from replay[^\n]*cat \/etc\/resolv\.conf/);
|
||||
assert.doesNotMatch(flat, /HOSTS_BYTES/);
|
||||
assert.doesNotMatch(flat, /RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import { buildPromptWithTerminalSelectionAttachments } from "../../application/state/terminalSelectionAttachment.ts";
|
||||
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from "./cattyHistoryReplay.ts";
|
||||
|
||||
type ExternalAgentHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = ExternalAgentHistoryMessage & { sourceId: string };
|
||||
@@ -62,7 +62,7 @@ function isDurableConstraintText(value: string): boolean {
|
||||
|
||||
function getUserHistoryContent(message: ChatMessage): string {
|
||||
if (message.role !== "user") return message.content || "";
|
||||
return buildPromptWithTerminalSelectionAttachments(
|
||||
return buildHistoricalUserReplayContent(
|
||||
message.content || "",
|
||||
message.attachments ?? [],
|
||||
);
|
||||
@@ -127,7 +127,6 @@ function summarizeToolMessage(
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
@@ -139,7 +138,10 @@ function summarizeToolMessage(
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
|
||||
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
|
||||
: undefined);
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(normalizeWhitespace(replayContent), MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -247,13 +249,11 @@ function toRawHistoryMessage(
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). external agent replay only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
// Keep recent tool results self-describing while replacing terminal
|
||||
// output with placeholders, so stale-session recovery doesn't replay
|
||||
// bulky command output on every follow-up. External agent replay only
|
||||
// supports user/assistant roles, so we flatten to "assistant" — the
|
||||
// tool results were produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
@@ -266,7 +266,10 @@ function toRawHistoryMessage(
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
const replayContent = buildHistoricalToolResultReplayText(result, callInfo
|
||||
? { id: result.toolCallId, name: callInfo.name, arguments: callInfo.arguments as Record<string, unknown> }
|
||||
: undefined);
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${replayContent}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
ExternalAgentConfig,
|
||||
ProviderAdvancedParams,
|
||||
ProviderConfig,
|
||||
ToolResult,
|
||||
WebSearchConfig,
|
||||
} from '../../../infrastructure/ai/types';
|
||||
import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
@@ -35,17 +36,30 @@ import {
|
||||
prepareContextCompaction,
|
||||
resolveContextWindow,
|
||||
} from '../../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
compressMessagesForRequestTooLargeRetry,
|
||||
} from '../../../infrastructure/ai/requestPayloadCompression';
|
||||
import {
|
||||
createCattyRequestTooLargeRetryError,
|
||||
hadToolProgressBeforeRequestTooLarge,
|
||||
} from '../../../infrastructure/ai/cattyRequestTooLargeRetry';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getExternalAgentSdkBackend } from '../../../infrastructure/ai/managedAgents';
|
||||
import { runSdkAgentTurn } from '../../../infrastructure/ai/sdkAgentAdapter';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { classifyError, isRequestTooLargeError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
isTerminalSelectionAttachment,
|
||||
} from '../../../application/state/terminalSelectionAttachment';
|
||||
import { latestAISessionsSnapshot } from '../../../application/state/aiStateSnapshots';
|
||||
import {
|
||||
buildHistoricalToolReplayMaps,
|
||||
buildHistoricalToolResultReplayText,
|
||||
buildHistoricalUserReplayContent,
|
||||
} from '../cattyHistoryReplay';
|
||||
import {
|
||||
extractProviderContinuationFromRawChunk,
|
||||
getOpenAIChatAssistantFieldsForHistoryMessage,
|
||||
@@ -334,6 +348,7 @@ export function useAIChatStreaming({
|
||||
// Track the current assistant message ID so updates target the correct message
|
||||
let activeMsgId = currentAssistantMsgId;
|
||||
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
|
||||
let hadToolProgress = false;
|
||||
const reader = result.fullStream.getReader();
|
||||
|
||||
// -- Text-delta batching: accumulate deltas and flush periodically --
|
||||
@@ -409,7 +424,16 @@ export function useAIChatStreaming({
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
let readResult: ReadableStreamReadResult<unknown>;
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (readErr) {
|
||||
if (isRequestTooLargeError(readErr)) {
|
||||
throw createCattyRequestTooLargeRetryError(readErr, hadToolProgress);
|
||||
}
|
||||
throw readErr;
|
||||
}
|
||||
const { done, value } = readResult;
|
||||
if (done) break;
|
||||
// Use the StreamChunk union for type narrowing instead of unsafe casts
|
||||
const chunk = value as StreamChunk;
|
||||
@@ -476,6 +500,7 @@ export function useAIChatStreaming({
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolCallChunk;
|
||||
hadToolProgress = true;
|
||||
const messageId = ensureAssistantMessage();
|
||||
const providerOptions = normalizeProviderContinuationOptions(typedChunk.providerMetadata);
|
||||
updateMessageById(streamSessionId, messageId, msg => ({
|
||||
@@ -501,6 +526,7 @@ export function useAIChatStreaming({
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
const typedChunk = chunk as ToolResultChunk;
|
||||
hadToolProgress = true;
|
||||
// Mark the assistant message's tool execution as completed
|
||||
updateMessageById(streamSessionId, activeMsgId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
@@ -547,6 +573,14 @@ export function useAIChatStreaming({
|
||||
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
|
||||
break;
|
||||
}
|
||||
if (isRequestTooLargeError(typedChunk.error)) {
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
throw createCattyRequestTooLargeRetryError(
|
||||
typedChunk.error,
|
||||
hadToolProgress,
|
||||
);
|
||||
}
|
||||
cancelPendingFlush();
|
||||
flushText();
|
||||
updateMessageById(streamSessionId, activeMsgId, msg => ({
|
||||
@@ -779,73 +813,86 @@ export function useAIChatStreaming({
|
||||
};
|
||||
|
||||
try {
|
||||
// Issue #5: Build SDK messages including tool-call and tool-result messages
|
||||
// so the LLM maintains full conversation context
|
||||
const allMessages = currentSession?.messages ?? [];
|
||||
let openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
const buildSdkMessages = (
|
||||
allMessages: ChatMessage[],
|
||||
includeCurrentUserMessage: boolean,
|
||||
{
|
||||
preserveTerminalToolResults = new Set<ToolResult>(),
|
||||
}: {
|
||||
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
|
||||
} = {},
|
||||
): Array<ModelMessage> => {
|
||||
const { resolvedToolCallsByAssistant, toolCallByToolResult } = buildHistoricalToolReplayMaps(allMessages);
|
||||
const nextFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
let previousHistoryMessageWasToolResult = false;
|
||||
|
||||
// Collect all tool call IDs that have a corresponding tool result,
|
||||
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
|
||||
const resolvedToolCallIds = new Set<string>();
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'tool' && m.toolResults) {
|
||||
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
const findToolName = (toolCallId: string): string => {
|
||||
for (const prev of allMessages) {
|
||||
if (prev.role === 'assistant' && prev.toolCalls) {
|
||||
const tc = prev.toolCalls.find(t => t.id === toolCallId);
|
||||
if (tc) return tc.name;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
const openAIChatAssistantFieldsByMessage = new Map<ModelMessage, OpenAIChatAssistantFields | undefined>();
|
||||
let previousHistoryMessageWasToolResult = false;
|
||||
for (const m of allMessages) {
|
||||
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
|
||||
if (m.role === 'user') {
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
const modelText = messageAttachments?.length
|
||||
? buildPromptWithTerminalSelectionAttachments(m.content, messageAttachments)
|
||||
: m.content;
|
||||
const modelAttachments = messageAttachments?.filter(
|
||||
(attachment) => !isTerminalSelectionAttachment(attachment),
|
||||
);
|
||||
if (modelAttachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: modelText });
|
||||
for (const att of modelAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
for (const m of allMessages) {
|
||||
const currentMessageFollowsToolResult = previousHistoryMessageWasToolResult;
|
||||
if (m.role === 'user') {
|
||||
// Historical attachments are replayed as placeholders so screenshots,
|
||||
// files, and terminal selections do not balloon every follow-up request.
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
sdkMessages.push({
|
||||
role: 'user',
|
||||
content: buildHistoricalUserReplayContent(m.content, messageAttachments ?? []),
|
||||
});
|
||||
} else if (m.role === 'assistant') {
|
||||
const activeContinuation = isProviderContinuationForSource(
|
||||
m.providerContinuation,
|
||||
continuationContext.source,
|
||||
)
|
||||
? m.providerContinuation
|
||||
: undefined;
|
||||
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
m,
|
||||
continuationContext.source,
|
||||
);
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
const resolvedToolCalls = resolvedToolCallsByAssistant.get(m);
|
||||
const resolvedCalls = resolvedToolCalls
|
||||
? m.toolCalls.filter(tc => resolvedToolCalls.has(tc))
|
||||
: [];
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
if (resolvedCalls.length > 0) {
|
||||
for (const part of activeContinuation?.reasoningParts ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: modelText });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
const activeContinuation = isProviderContinuationForSource(
|
||||
m.providerContinuation,
|
||||
continuationContext.source,
|
||||
)
|
||||
? m.providerContinuation
|
||||
: undefined;
|
||||
const openAIChatAssistantFields = getOpenAIChatAssistantFieldsForHistoryMessage(
|
||||
m,
|
||||
continuationContext.source,
|
||||
);
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
if (resolvedCalls.length > 0) {
|
||||
if (m.content) {
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
}
|
||||
for (const tc of resolvedCalls) {
|
||||
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
|
||||
sdkMessages.push(message);
|
||||
if (resolvedCalls.length > 0) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.content) {
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
for (const part of activeContinuation?.reasoningParts ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
@@ -854,84 +901,91 @@ export function useAIChatStreaming({
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (m.content) {
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
});
|
||||
}
|
||||
for (const tc of resolvedCalls) {
|
||||
const providerOptions = activeContinuation?.toolCallProviderOptionsById?.[tc.id];
|
||||
contentParts.push({
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
input: tc.arguments ?? {},
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
// If all tool calls were orphaned, just include the text content
|
||||
if (contentParts.length > 0) {
|
||||
const message: ModelMessage = { role: 'assistant', content: toAssistantModelContent(contentParts) };
|
||||
const message: ModelMessage = {
|
||||
role: 'assistant',
|
||||
content: toAssistantModelContent(contentParts),
|
||||
};
|
||||
sdkMessages.push(message);
|
||||
if (resolvedCalls.length > 0) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
|
||||
if (currentMessageFollowsToolResult) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, nextFieldsByMessage);
|
||||
}
|
||||
}
|
||||
} else if (m.content) {
|
||||
const contentParts: AssistantContentPart[] = [];
|
||||
for (const part of activeContinuation?.reasoningParts ?? []) {
|
||||
if (!part.text && !part.providerOptions) continue;
|
||||
contentParts.push({
|
||||
type: 'reasoning' as const,
|
||||
text: part.text,
|
||||
...(part.providerOptions ? { providerOptions: part.providerOptions } : {}),
|
||||
});
|
||||
}
|
||||
contentParts.push({
|
||||
type: 'text' as const,
|
||||
text: m.content,
|
||||
...(activeContinuation?.textProviderOptions ? { providerOptions: activeContinuation.textProviderOptions } : {}),
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => {
|
||||
const toolCall = toolCallByToolResult.get(tr);
|
||||
return {
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: toolCall?.name ?? 'unknown',
|
||||
output: {
|
||||
type: 'text' as const,
|
||||
value: buildHistoricalToolResultReplayText(tr, toolCall, {
|
||||
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
const message: ModelMessage = {
|
||||
role: 'assistant',
|
||||
content: toAssistantModelContent(contentParts),
|
||||
};
|
||||
sdkMessages.push(message);
|
||||
if (currentMessageFollowsToolResult) {
|
||||
rememberOpenAIChatAssistantFields(message, openAIChatAssistantFields, openAIChatAssistantFieldsByMessage);
|
||||
}
|
||||
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
|
||||
}
|
||||
|
||||
if (includeCurrentUserMessage) {
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
const modelText = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
|
||||
const modelAttachments = attachments.filter(
|
||||
(attachment) => !isTerminalSelectionAttachment(attachment),
|
||||
);
|
||||
if (!modelAttachments.length) {
|
||||
sdkMessages.push({ role: 'user', content: modelText });
|
||||
} else {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: modelText });
|
||||
for (const att of modelAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
}
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: findToolName(tr.toolCallId),
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
}
|
||||
previousHistoryMessageWasToolResult = m.role === 'tool' && !!m.toolResults?.length;
|
||||
}
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: trimmed });
|
||||
for (const att of attachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
openAIChatAssistantFieldsByMessage = nextFieldsByMessage;
|
||||
return sdkMessages;
|
||||
};
|
||||
|
||||
const sdkMessages = buildSdkMessages(currentSession?.messages ?? [], true);
|
||||
const collectToolResultsAfterMessage = (
|
||||
messages: ChatMessage[],
|
||||
messageId: string,
|
||||
): Set<ToolResult> => {
|
||||
const results = new Set<ToolResult>();
|
||||
let afterMessage = false;
|
||||
for (const message of messages) {
|
||||
if (message.id === messageId) {
|
||||
afterMessage = true;
|
||||
continue;
|
||||
}
|
||||
if (!afterMessage || message.role !== 'tool' || !message.toolResults?.length) continue;
|
||||
for (const result of message.toolResults) {
|
||||
results.add(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// Create model with placeholder API key — the main process injects the real
|
||||
// decrypted key when the HTTP request is proxied through IPC, so plaintext
|
||||
@@ -959,63 +1013,178 @@ export function useAIChatStreaming({
|
||||
defaultContextWindow: DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
});
|
||||
const outputReserveTokens = Math.min(4096, Math.ceil(contextWindow * 0.05));
|
||||
const requestReserveTokens = outputReserveTokens + estimateUnknownTokens({
|
||||
const getRequestReserveTokens = () => outputReserveTokens + estimateUnknownTokens({
|
||||
systemPrompt,
|
||||
toolNames: Object.keys(tools),
|
||||
openAIChatAssistantFields: Array.from(openAIChatAssistantFieldsByMessage.values()),
|
||||
});
|
||||
|
||||
let messagesForStream = sdkMessages;
|
||||
try {
|
||||
const compacted = await prepareContextCompaction({
|
||||
messages: sdkMessages,
|
||||
contextWindow,
|
||||
reservedTokens: requestReserveTokens,
|
||||
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
summarize: async (messagesToSummarize) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
|
||||
}],
|
||||
abortSignal: abortController.signal,
|
||||
maxOutputTokens: 1600,
|
||||
temperature: 0,
|
||||
});
|
||||
return result.text;
|
||||
},
|
||||
const summarizeForCompaction = async (messagesToSummarize: ModelMessage[]) => {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: 'Compacting earlier context...' }));
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: CONTEXT_COMPACTION_SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Summarize this earlier conversation context for the next model turn:\n\n${formatMessagesForCompaction(messagesToSummarize)}`,
|
||||
}],
|
||||
abortSignal: abortController.signal,
|
||||
maxOutputTokens: 1600,
|
||||
temperature: 0,
|
||||
});
|
||||
messagesForStream = compacted.messages;
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) throw err;
|
||||
console.warn('[Catty] Context compaction failed; falling back to recent messages only:', err);
|
||||
messagesForStream = keepRecentContextMessages(sdkMessages, DEFAULT_PROTECT_RECENT_MESSAGES);
|
||||
}
|
||||
return result.text;
|
||||
};
|
||||
const prepareMessagesForStream = (messages: ModelMessage[]): ModelMessage[] => {
|
||||
const pruned = pruneMessages({
|
||||
messages,
|
||||
reasoning: 'all',
|
||||
emptyMessages: 'remove',
|
||||
});
|
||||
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
|
||||
pruned,
|
||||
openAIChatAssistantFieldsByMessage,
|
||||
);
|
||||
return pruned;
|
||||
};
|
||||
const compactMessages = async (
|
||||
messages: ModelMessage[],
|
||||
{
|
||||
force = false,
|
||||
statusText,
|
||||
fallbackLog,
|
||||
compressForRequestTooLargeRetry = false,
|
||||
compressionLog,
|
||||
}: {
|
||||
force?: boolean;
|
||||
statusText?: string;
|
||||
fallbackLog: string;
|
||||
compressForRequestTooLargeRetry?: boolean;
|
||||
compressionLog?: string;
|
||||
},
|
||||
): Promise<ModelMessage[]> => {
|
||||
const compressRetryMessages = (candidateMessages: ModelMessage[], log?: string): ModelMessage[] => {
|
||||
if (!compressForRequestTooLargeRetry) return candidateMessages;
|
||||
const compressed = compressMessagesForRequestTooLargeRetry(candidateMessages);
|
||||
if (compressed.didAdjust && log) {
|
||||
console.warn(log);
|
||||
}
|
||||
return compressed.messages;
|
||||
};
|
||||
|
||||
messagesForStream = pruneMessages({
|
||||
messages: messagesForStream,
|
||||
reasoning: 'all',
|
||||
emptyMessages: 'remove',
|
||||
try {
|
||||
if (statusText) {
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText }));
|
||||
}
|
||||
const inputMessages = compressRetryMessages(messages, compressionLog);
|
||||
const compacted = await prepareContextCompaction({
|
||||
messages: inputMessages,
|
||||
contextWindow,
|
||||
reservedTokens: getRequestReserveTokens(),
|
||||
thresholdRatio: force ? 0 : undefined,
|
||||
protectRecentMessages: DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
summarize: summarizeForCompaction,
|
||||
});
|
||||
let nextMessages = force && !compacted.didCompact
|
||||
? keepRecentContextMessages(inputMessages, DEFAULT_PROTECT_RECENT_MESSAGES)
|
||||
: compacted.messages;
|
||||
return compressRetryMessages(nextMessages);
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) throw err;
|
||||
console.warn(fallbackLog, err);
|
||||
const fallbackMessages = keepRecentContextMessages(messages, DEFAULT_PROTECT_RECENT_MESSAGES);
|
||||
if (!compressForRequestTooLargeRetry) {
|
||||
return fallbackMessages;
|
||||
}
|
||||
const compressed = compressMessagesForRequestTooLargeRetry(fallbackMessages);
|
||||
if (compressed.didAdjust) {
|
||||
console.warn('[Catty] Request content compressed after compaction fallback.');
|
||||
}
|
||||
return compressed.messages;
|
||||
}
|
||||
};
|
||||
let messagesForStream = sdkMessages;
|
||||
messagesForStream = await compactMessages(messagesForStream, {
|
||||
fallbackLog: '[Catty] Context compaction failed; falling back to recent messages only:',
|
||||
});
|
||||
continuationContext.openAIChatAssistantFields = collectOpenAIChatAssistantFieldsForMessages(
|
||||
messagesForStream,
|
||||
openAIChatAssistantFieldsByMessage,
|
||||
);
|
||||
|
||||
await processCattyStream(
|
||||
sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
messagesForStream,
|
||||
abortController.signal,
|
||||
assistantMsgId,
|
||||
context.activeProvider?.advancedParams,
|
||||
continuationContext,
|
||||
);
|
||||
messagesForStream = prepareMessagesForStream(messagesForStream);
|
||||
|
||||
try {
|
||||
await processCattyStream(
|
||||
sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
messagesForStream,
|
||||
abortController.signal,
|
||||
assistantMsgId,
|
||||
context.activeProvider?.advancedParams,
|
||||
continuationContext,
|
||||
);
|
||||
} catch (streamErr) {
|
||||
if (abortController.signal.aborted || !isRequestTooLargeError(streamErr)) {
|
||||
throw streamErr;
|
||||
}
|
||||
|
||||
console.warn('[Catty] Request hit HTTP 413; forcing context compaction and retrying once.', streamErr);
|
||||
const statusText = 'Request was too large. Compacting context and retrying...';
|
||||
const hadToolProgress = hadToolProgressBeforeRequestTooLarge(streamErr);
|
||||
let retryBaseMessages = messagesForStream;
|
||||
let retryAssistantMsgId = assistantMsgId;
|
||||
if (hadToolProgress) {
|
||||
const latestSession = latestAISessionsSnapshot?.find(session => session.id === sessionId);
|
||||
if (latestSession) {
|
||||
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
|
||||
preserveTerminalToolResults: collectToolResultsAfterMessage(
|
||||
latestSession.messages,
|
||||
assistantMsgId,
|
||||
),
|
||||
});
|
||||
}
|
||||
retryAssistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: retryAssistantMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
model: activeModelId || context.activeProvider?.defaultModel || '',
|
||||
providerId: context.activeProvider?.providerId,
|
||||
statusText,
|
||||
});
|
||||
} else {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({
|
||||
...msg,
|
||||
content: '',
|
||||
thinking: undefined,
|
||||
thinkingDurationMs: undefined,
|
||||
providerContinuation: undefined,
|
||||
toolCalls: undefined,
|
||||
errorInfo: undefined,
|
||||
executionStatus: undefined,
|
||||
pendingApproval: undefined,
|
||||
statusText,
|
||||
}));
|
||||
}
|
||||
const retryMessages = prepareMessagesForStream(await compactMessages(retryBaseMessages, {
|
||||
force: true,
|
||||
statusText,
|
||||
fallbackLog: '[Catty] Forced context compaction after 413 failed; falling back to recent messages only:',
|
||||
compressForRequestTooLargeRetry: true,
|
||||
compressionLog: '[Catty] Request content compressed after forced context compaction.',
|
||||
}));
|
||||
|
||||
await processCattyStream(
|
||||
sessionId,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
retryMessages,
|
||||
abortController.signal,
|
||||
retryAssistantMsgId,
|
||||
context.activeProvider?.advancedParams,
|
||||
continuationContext,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Catty] streamText error:', err);
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
@@ -1028,7 +1197,7 @@ export function useAIChatStreaming({
|
||||
}
|
||||
}, [
|
||||
processCattyStream, reportStreamError, setStreamingForScope,
|
||||
updateLastMessage,
|
||||
addMessageToSession, updateLastMessage, updateMessageById,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
78
components/customCssHooks.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function readProjectFile(path: string): string {
|
||||
return readFileSync(join(root.pathname, path), "utf8");
|
||||
}
|
||||
|
||||
test("terminal side panel exposes stable custom CSS regions", () => {
|
||||
const source = readProjectFile("components/terminalLayer/TerminalLayerSidePanelSection.tsx");
|
||||
|
||||
assert.match(source, /terminal-side-panel-shell/);
|
||||
assert.match(source, /terminal-side-panel-tabs/);
|
||||
assert.match(source, /terminal-side-panel-content/);
|
||||
assert.match(source, /terminal-side-panel-resizer/);
|
||||
assert.match(source, /isSidePanelOpenForCurrentTab \? 'terminal-side-panel' : undefined/);
|
||||
});
|
||||
|
||||
test("terminal side panel shell is isolated from surrounding layout churn", () => {
|
||||
const source = readProjectFile("components/terminalLayer/TerminalLayerSidePanelSection.tsx");
|
||||
|
||||
assert.match(source, /contain: 'layout paint style'/);
|
||||
});
|
||||
|
||||
test("SFTP panel exposes stable custom CSS regions", () => {
|
||||
const source = [
|
||||
readProjectFile("components/SftpSidePanel.tsx"),
|
||||
readProjectFile("components/sftp/SftpPaneView.tsx"),
|
||||
readProjectFile("components/sftp/SftpPaneToolbar.tsx"),
|
||||
readProjectFile("components/sftp/SftpPaneFileList.tsx"),
|
||||
readProjectFile("components/sftp/SftpFileRow.tsx"),
|
||||
readProjectFile("components/sftp/SftpPaneTreeView.tsx"),
|
||||
readProjectFile("components/sftp/SftpPaneTreeNode.tsx"),
|
||||
readProjectFile("components/sftp/SftpTransferQueue.tsx"),
|
||||
].join("\n");
|
||||
|
||||
[
|
||||
"terminal-sftp-panel",
|
||||
"terminal-sftp-host-header",
|
||||
"terminal-sftp-pane",
|
||||
"terminal-sftp-toolbar",
|
||||
"terminal-sftp-path",
|
||||
"terminal-sftp-filter-bar",
|
||||
"terminal-sftp-list",
|
||||
"terminal-sftp-list-header",
|
||||
"terminal-sftp-list-row",
|
||||
"terminal-sftp-tree",
|
||||
"terminal-sftp-tree-row",
|
||||
"terminal-sftp-transfer-queue",
|
||||
"terminal-sftp-transfer-queue-header",
|
||||
"terminal-sftp-transfer-list",
|
||||
].forEach((hook) => assert.match(source, new RegExp(hook)));
|
||||
});
|
||||
|
||||
test("terminal host tree exposes stable custom CSS regions", () => {
|
||||
const source = readProjectFile("components/terminalLayer/TerminalHostTreeSidebar.tsx");
|
||||
|
||||
assert.match(source, /terminal-host-tree-sidebar-shell/);
|
||||
assert.match(source, /terminal-host-tree-sidebar/);
|
||||
assert.match(source, /terminal-host-tree-sidebar-content/);
|
||||
});
|
||||
|
||||
test("custom CSS help lists the expanded terminal and SFTP hooks", () => {
|
||||
const source = readProjectFile("application/i18n/locales/zh-CN/core.ts");
|
||||
|
||||
[
|
||||
"terminal-side-panel-tabs",
|
||||
"terminal-side-panel-content",
|
||||
"terminal-sftp-toolbar",
|
||||
"terminal-sftp-list-row",
|
||||
"terminal-sftp-tree-row",
|
||||
"terminal-sftp-transfer-queue",
|
||||
"terminal-host-tree-sidebar-content",
|
||||
].forEach((hook) => assert.match(source, new RegExp(hook)));
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
canPromoteTextEditor,
|
||||
getTextEditorContentStats,
|
||||
isTextEditorReadOnly,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
@@ -43,3 +44,8 @@ test("renders the promote button disabled while a save is running", () => {
|
||||
assert.match(savingMarkup, /disabled=""/);
|
||||
assert.doesNotMatch(idleMarkup, /disabled=""/);
|
||||
});
|
||||
|
||||
test("counts editor content without allocating line arrays", () => {
|
||||
assert.deepEqual(getTextEditorContentStats(""), { lineCount: 1, charCount: 0 });
|
||||
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
|
||||
});
|
||||
|
||||
@@ -146,13 +146,13 @@ const getEditorColors = (isDark: boolean): EditorColors => ({
|
||||
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
|
||||
});
|
||||
|
||||
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
|
||||
/** Build a fingerprint string so we can detect UI theme color changes cheaply. */
|
||||
const getThemeSignal = (): string => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
const root = document.documentElement;
|
||||
return root.dataset.immersiveTheme
|
||||
return root.dataset.activeChromeTheme
|
||||
?? getComputedStyle(root).getPropertyValue('--background').trim();
|
||||
};
|
||||
|
||||
@@ -182,28 +182,37 @@ export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean =
|
||||
|
||||
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
|
||||
|
||||
export function getTextEditorContentStats(content: string): { lineCount: number; charCount: number } {
|
||||
let lineCount = 1;
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
if (content.charCodeAt(i) === 10) lineCount += 1;
|
||||
}
|
||||
return { lineCount, charCount: content.length };
|
||||
}
|
||||
|
||||
export const TextEditorPromoteButton: React.FC<{
|
||||
saving: boolean;
|
||||
onPromoteToTab: () => void;
|
||||
title: string;
|
||||
}> = ({ saving, onPromoteToTab, title }) => (
|
||||
}> = React.memo(({ saving, onPromoteToTab, title }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-6 w-6"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
<Maximize2 size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
));
|
||||
TextEditorPromoteButton.displayName = 'TextEditorPromoteButton';
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
fileName,
|
||||
content,
|
||||
languageId,
|
||||
@@ -238,13 +247,13 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
);
|
||||
|
||||
// Track a signal that changes whenever immersive-mode or base theme colors change
|
||||
// Track a signal that changes whenever active chrome or base theme colors change
|
||||
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
|
||||
|
||||
// Custom theme name
|
||||
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
|
||||
|
||||
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
|
||||
// Define and update custom Monaco themes from active chrome / base UI colors
|
||||
useEffect(() => {
|
||||
if (!monaco) return;
|
||||
|
||||
@@ -284,7 +293,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
monaco.editor.setTheme(customThemeName);
|
||||
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
|
||||
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
|
||||
// Listen for theme changes via MutationObserver on <html> class, style, and active chrome attr
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
@@ -295,7 +304,7 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'data-immersive-theme'],
|
||||
attributeFilter: ['class', 'style', 'data-active-chrome-theme'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
@@ -465,6 +474,8 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
|
||||
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
|
||||
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
|
||||
const languageName = useMemo(() => getLanguageName(languageId), [languageId]);
|
||||
const contentStats = useMemo(() => getTextEditorContentStats(content), [content]);
|
||||
const languageOptions = useMemo(
|
||||
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
|
||||
[supportedLanguages],
|
||||
@@ -477,35 +488,35 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate flex-shrink-0">
|
||||
<div className="h-9 px-3 py-1.5 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex h-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold leading-none truncate flex-shrink-0">
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs text-muted-foreground truncate cursor-default">
|
||||
<span className="text-xs leading-none text-muted-foreground truncate cursor-default">
|
||||
{subtitle}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{subtitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
|
||||
{saveError && <span className="text-xs leading-none text-destructive truncate">{saveError}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex h-6 items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-6 w-6"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={14} />
|
||||
<Search size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.search')}</TooltipContent>
|
||||
@@ -517,10 +528,10 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-6 w-6"
|
||||
onClick={onToggleWordWrap}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
<WrapText size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
|
||||
@@ -532,21 +543,21 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
value={languageId}
|
||||
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
|
||||
placeholder={t('sftp.editor.syntaxHighlight')}
|
||||
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
|
||||
triggerClassName="h-6 max-w-[170px] min-w-[112px] text-xs"
|
||||
/>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
className="h-6 px-2.5 text-xs"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
<Loader2 size={13} className="mr-1 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload size={14} className="mr-1.5" />
|
||||
<CloudUpload size={13} className="mr-1" />
|
||||
)}
|
||||
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
|
||||
</Button>
|
||||
@@ -565,10 +576,10 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-6 w-6"
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={13} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -618,14 +629,17 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
|
||||
<span>
|
||||
{getLanguageName(languageId)}
|
||||
{languageName}
|
||||
</span>
|
||||
<span>
|
||||
{content.split('\n').length} lines • {content.length} characters
|
||||
{contentStats.lineCount} lines • {contentStats.charCount} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TextEditorPane = React.memo(TextEditorPaneInner);
|
||||
TextEditorPane.displayName = 'TextEditorPane';
|
||||
|
||||
export default TextEditorPane;
|
||||
|
||||
30
components/editor/TextEditorTabView.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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 { getTextEditorTabShellStyle } = await import('./TextEditorTabView');
|
||||
|
||||
test('visible editor tab leaves room for the terminal host sidebar', () => {
|
||||
assert.deepEqual(getTextEditorTabShellStyle(true, 280), {
|
||||
zIndex: 20,
|
||||
left: 280,
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden editor tab stays hidden', () => {
|
||||
assert.deepEqual(getTextEditorTabShellStyle(false, 280), {
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
zIndex: 20,
|
||||
left: 280,
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { saveEditorTab } from '../../application/state/editorTabSave';
|
||||
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
|
||||
import { useIsEditorTabActive } from '../../application/state/activeTabStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
|
||||
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
|
||||
import type { Host } from '../../types';
|
||||
import { toast } from '../ui/toast';
|
||||
@@ -27,6 +28,14 @@ export interface TextEditorTabViewProps {
|
||||
onRequestClose: (tabId: EditorTabId) => void;
|
||||
}
|
||||
|
||||
export function getTextEditorTabShellStyle(isVisible: boolean, hostTreeLayoutWidth: number): React.CSSProperties {
|
||||
return {
|
||||
...(isVisible ? null : { pointerEvents: 'none', visibility: 'hidden' }),
|
||||
zIndex: 20,
|
||||
left: hostTreeLayoutWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
tabId,
|
||||
hotkeyScheme,
|
||||
@@ -39,6 +48,7 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
// Self-subscribe visibility so switching tabs only re-renders this editor
|
||||
// instance, not AppView/App.
|
||||
const isVisible = useIsEditorTabActive(tabId);
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
|
||||
@@ -70,6 +80,10 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
}
|
||||
}, [tabId, t]);
|
||||
|
||||
const handleRequestClose = useCallback(() => {
|
||||
onRequestClose(tabId);
|
||||
}, [onRequestClose, tabId]);
|
||||
|
||||
// Tab has been closed — render nothing (parent should remove this instance,
|
||||
// but guard here in case of a transient render before unmount).
|
||||
if (!tab) return null;
|
||||
@@ -87,18 +101,17 @@ export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
|
||||
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
|
||||
// an inactive editor tab doesn't collapse to zero height in normal flow,
|
||||
// and an active one fills the viewport instead of stacking beneath others.
|
||||
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
|
||||
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
|
||||
// its children's stacking contexts can still overlap without an explicit z.)
|
||||
// z-index high enough to stay above the terminal workspace while leaving
|
||||
// room for the shared host sidebar when it is open.
|
||||
<div
|
||||
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
|
||||
className="absolute inset-0 min-h-0 flex flex-col bg-background"
|
||||
style={getTextEditorTabShellStyle(isVisible, hostTreeLayoutWidth)}
|
||||
className="absolute top-0 right-0 bottom-0 min-h-0 flex flex-col bg-background"
|
||||
>
|
||||
<TextEditorPane
|
||||
chrome="tab"
|
||||
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
|
||||
subtitle={subtitle}
|
||||
onRequestClose={() => onRequestClose(tabId)}
|
||||
onRequestClose={handleRequestClose}
|
||||
content={tab.content}
|
||||
languageId={tab.languageId}
|
||||
wordWrap={tab.wordWrap}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileSymlink, Folder, FolderOpen, Monitor, Server } from 'lucide-react';
|
||||
import { Copy, FileSymlink, Folder, FolderOpen, Monitor, Pencil, Server } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
@@ -8,6 +8,8 @@ import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
|
||||
|
||||
export interface HostTreeHostContextMenuHandlers {
|
||||
onConnect: (host: Host) => void;
|
||||
onRenameHost?: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
}
|
||||
@@ -17,6 +19,8 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
> = ({
|
||||
host,
|
||||
onConnect,
|
||||
onRenameHost,
|
||||
onDuplicateHost,
|
||||
onCopyCredentials,
|
||||
onDeleteHost,
|
||||
}) => {
|
||||
@@ -28,6 +32,14 @@ export const HostTreeHostContextMenuContent: React.FC<
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
{onRenameHost && (
|
||||
<ContextMenuItem onClick={() => onRenameHost(host)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -43,11 +43,23 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-inline-group-edit="true"
|
||||
value={value}
|
||||
draggable={false}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
@@ -60,7 +72,7 @@ export const HostTreeGroupInlineRenameInput: React.FC<HostTreeGroupInlineRenameI
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Identity } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { VaultEntityIcon, vaultIdentityIconClass } from '../vault/VaultEntityIcon';
|
||||
|
||||
interface IdentityCardProps {
|
||||
identity: Identity;
|
||||
@@ -52,9 +53,10 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-green-500/15 text-green-500 flex items-center justify-center">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={vaultIdentityIconClass}
|
||||
icon={<User size={18} />}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{identity.label || 'Add a label...'}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
|
||||
@@ -73,7 +73,7 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-500/15 text-green-500 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-lg bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center">
|
||||
<User size={20} />
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -8,6 +8,11 @@ import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
VaultEntityIcon,
|
||||
vaultCertificateIconClass,
|
||||
vaultKeyIconClass,
|
||||
} from '../vault/VaultEntityIcon';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -55,14 +60,12 @@ export const KeyCard: React.FC<KeyCardProps> = ({
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
keyItem.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary"
|
||||
)}>
|
||||
{getKeyIcon(keyItem)}
|
||||
</div>
|
||||
<VaultEntityIcon
|
||||
className={keyItem.certificate
|
||||
? vaultCertificateIconClass
|
||||
: vaultKeyIconClass}
|
||||
icon={getKeyIcon(keyItem)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{keyItem.label}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { vaultEntityIconClass } from '../vault/VaultEntityIcon';
|
||||
import { getStatusColor,getTypeColor } from './utils';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
@@ -60,7 +61,8 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className={cn(
|
||||
"h-11 w-11 rounded-xl flex items-center justify-center text-sm font-bold transition-colors",
|
||||
vaultEntityIconClass,
|
||||
"text-sm font-bold transition-colors",
|
||||
getTypeColor(rule.type, isActive)
|
||||
)}>
|
||||
{rule.type[0].toUpperCase()}
|
||||
|
||||